From a9f7645683e789276dd01dc4758b318cc7e63ef2 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Wed, 5 Aug 2026 14:35:27 +0700 Subject: [PATCH] Sort waitlist signups --- app/api/waitlist/manage/route.ts | 11 +++++++-- app/dashboard/[[...tab]]/page.tsx | 37 ++++++++++++++++++++++++------ lib/db.ts | 14 ++++++++++-- lib/waitlist-filter.ts | 9 ++++++++ tests/waitlist-filter.test.mjs | 10 ++++++++ tests/waitlist-sort-db.test.mjs | 38 +++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 tests/waitlist-sort-db.test.mjs diff --git a/app/api/waitlist/manage/route.ts b/app/api/waitlist/manage/route.ts index a176bb5..384a23b 100644 --- a/app/api/waitlist/manage/route.ts +++ b/app/api/waitlist/manage/route.ts @@ -5,6 +5,7 @@ import { safeDomain } from "@/lib/config"; import { filterSignupsByQuery, filterSignupsByStatus, + parseWaitlistSort, parseWaitlistStatus, } from "@/lib/waitlist-filter"; @@ -20,7 +21,8 @@ async function accountId(req: NextRequest): Promise { return null; } -// GET /api/waitlist/manage?dn=[&format=csv][&status=verified|pending][&q=] +// GET /api/waitlist/manage?dn=[&format=csv][&status=verified|pending] +// [&q=][&sort=newest|oldest|email] // — the signups for one of the caller's parked domains. Each domain's // waitlist is separate (signups.dn). export async function GET(req: NextRequest) { @@ -33,9 +35,13 @@ export async function GET(req: NextRequest) { } const status = parseWaitlistStatus(req.nextUrl.searchParams.get("status")); if (!status) return NextResponse.json({ error: "invalid waitlist status" }, { status: 400 }); + const sort = parseWaitlistSort(req.nextUrl.searchParams.get("sort")); + if (!sort) return NextResponse.json({ error: "invalid waitlist sort" }, { status: 400 }); const query = req.nextUrl.searchParams.get("q") || ""; - const allSignups = await listDomainSignups(dn); + // Apply the requested order in SQL before the 1,000-row safety limit. If + // this were sorted here, "oldest" would only reorder the newest rows. + const allSignups = await listDomainSignups(dn, 1000, sort); const signups = filterSignupsByQuery( filterSignupsByStatus(allSignups, status), query, @@ -58,6 +64,7 @@ export async function GET(req: NextRequest) { count: signups.length, total: allSignups.length, status, + sort, query, signups, }); diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx index 64a811e..59b31d5 100644 --- a/app/dashboard/[[...tab]]/page.tsx +++ b/app/dashboard/[[...tab]]/page.tsx @@ -6,6 +6,7 @@ import { formatStoredWebhookPayload } from "@/lib/webhook-payload"; import { filterSignupsByQuery, filterSignupsByStatus, + type WaitlistSort, type WaitlistStatus, } from "@/lib/waitlist-filter"; // Tab values double as URL slugs: /dashboard/ (the default "page" tab lives at @@ -779,22 +780,29 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { const [active, setActive] = useState(null); const [signups, setSignups] = useState(null); const [status, setStatus] = useState("all"); + const [sort, setSort] = useState("newest"); const [query, setQuery] = useState(""); + const loadRequest = useRef(0); - const load = async (dn: string) => { + const load = async (dn: string, order: WaitlistSort = sort) => { + const request = ++loadRequest.current; setActive(dn); setSignups(null); try { - const r = await fetch(`/api/waitlist/manage?dn=${encodeURIComponent(dn)}`); + const r = await fetch(`/api/waitlist/manage?dn=${encodeURIComponent(dn)}&sort=${order}`); const d = await r.json(); + if (request !== loadRequest.current) return; if (!r.ok) throw new Error(d.error); setSignups(d.signups || []); - } catch (e: any) { onError(e.message || "Failed to load."); setSignups([]); } + } catch (e: any) { + if (request !== loadRequest.current) return; + onError(e.message || "Failed to load."); setSignups([]); + } }; useEffect(() => { fetch("/api/account").then((r) => r.json()).then((d) => { setDomains(d.parkedDomains || []); - if (d.parkedDomains?.[0]) load(d.parkedDomains[0].domain); + if (d.parkedDomains?.[0]) load(d.parkedDomains[0].domain, "newest"); }).catch(() => setDomains([])); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -808,7 +816,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { const filtered = matchingSignups ? filterSignupsByStatus(matchingSignups, status) : null; - const hasFilters = status !== "all" || query.trim() !== ""; + const hasFilters = status !== "all" || query.trim() !== "" || sort !== "newest"; const verifiedCount = matchingSignups?.filter((signup) => signup.verified).length ?? 0; const pendingCount = matchingSignups ? matchingSignups.length - verifiedCount : 0; const filters: { value: WaitlistStatus; label: string; count: number }[] = [ @@ -823,7 +831,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) {

Each parked domain keeps its own waitlist.

{domains.map((d) => ( - ))} @@ -832,7 +840,7 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { <>

{active} — {filtered ? filtered.length : "…"} signups

- + {hasFilters ? "Export filtered CSV" : "Export CSV"}
@@ -850,6 +858,21 @@ function WaitlistPanel({ onError }: { onError: (m: string) => void }) { Clear )} +
{filters.map((filter) => ( diff --git a/lib/db.ts b/lib/db.ts index 96adc8e..0049c31 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,5 +1,6 @@ import { createClient, type Client } from "@libsql/client"; import { randomBytes } from "node:crypto"; +import type { WaitlistSort } from "./waitlist-filter"; let _db: Client | undefined; @@ -1055,10 +1056,19 @@ export async function listInboundEvents(dn: string, limit = 50): Promise<{ id: s } /** The waitlist for one domain (that the caller owns). */ -export async function listDomainSignups(domain: string, limit = 1000): Promise<{ email: string; verified: boolean; ref: string | null; created_at: string }[]> { +export async function listDomainSignups( + domain: string, + limit = 1000, + sort: WaitlistSort = "newest", +): Promise<{ email: string; verified: boolean; ref: string | null; created_at: string }[]> { await ensureSchema(); + const orderBy: Record = { + newest: "created_at DESC, email COLLATE NOCASE ASC, email ASC", + oldest: "created_at ASC, email COLLATE NOCASE ASC, email ASC", + email: "email COLLATE NOCASE ASC, email ASC, created_at DESC", + }; const res = await db().execute({ - sql: `SELECT email, verified_at, ref, created_at FROM signups WHERE dn = ? ORDER BY created_at DESC LIMIT ?`, + sql: `SELECT email, verified_at, ref, created_at FROM signups WHERE dn = ? ORDER BY ${orderBy[sort]} LIMIT ?`, args: [domain.trim().toLowerCase(), limit], }); return res.rows.map((r) => ({ diff --git a/lib/waitlist-filter.ts b/lib/waitlist-filter.ts index 8098354..1c24173 100644 --- a/lib/waitlist-filter.ts +++ b/lib/waitlist-filter.ts @@ -1,6 +1,8 @@ export const WAITLIST_STATUSES = ["all", "verified", "pending"] as const; +export const WAITLIST_SORTS = ["newest", "oldest", "email"] as const; export type WaitlistStatus = (typeof WAITLIST_STATUSES)[number]; +export type WaitlistSort = (typeof WAITLIST_SORTS)[number]; export function parseWaitlistStatus(value: string | null): WaitlistStatus | null { if (value === null || value === "") return "all"; @@ -9,6 +11,13 @@ export function parseWaitlistStatus(value: string | null): WaitlistStatus | null : null; } +export function parseWaitlistSort(value: string | null): WaitlistSort | null { + if (value === null || value === "") return "newest"; + return WAITLIST_SORTS.includes(value as WaitlistSort) + ? (value as WaitlistSort) + : null; +} + export function filterSignupsByStatus( signups: T[], status: WaitlistStatus, diff --git a/tests/waitlist-filter.test.mjs b/tests/waitlist-filter.test.mjs index 5657e72..45d7245 100644 --- a/tests/waitlist-filter.test.mjs +++ b/tests/waitlist-filter.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { filterSignupsByQuery, filterSignupsByStatus, + parseWaitlistSort, parseWaitlistStatus, } from "../lib/waitlist-filter.ts"; @@ -16,6 +17,15 @@ test("parseWaitlistStatus accepts supported filters and defaults to all", () => assert.equal(parseWaitlistStatus("unknown"), null); }); +test("parseWaitlistSort accepts supported orders and defaults to newest", () => { + assert.equal(parseWaitlistSort(null), "newest"); + assert.equal(parseWaitlistSort(""), "newest"); + assert.equal(parseWaitlistSort("newest"), "newest"); + assert.equal(parseWaitlistSort("oldest"), "oldest"); + assert.equal(parseWaitlistSort("email"), "email"); + assert.equal(parseWaitlistSort("random"), null); +}); + test("filterSignupsByStatus keeps the requested verification state", () => { const signups = [ { email: "confirmed@example.com", verified: true }, diff --git a/tests/waitlist-sort-db.test.mjs b/tests/waitlist-sort-db.test.mjs new file mode 100644 index 0000000..40542b1 --- /dev/null +++ b/tests/waitlist-sort-db.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +process.env.TURSO_DATABASE_URL = "file::memory:"; + +const { db, ensureSchema, listDomainSignups } = await import("../lib/db.ts"); + +await ensureSchema(); + +await db().batch([ + { + sql: "INSERT INTO signups (email, dn, created_at) VALUES (?, ?, ?)", + args: ["middle@example.com", "sort.test", "2026-02-01 00:00:00"], + }, + { + sql: "INSERT INTO signups (email, dn, created_at) VALUES (?, ?, ?)", + args: ["zulu@example.com", "sort.test", "2026-01-01 00:00:00"], + }, + { + sql: "INSERT INTO signups (email, dn, created_at) VALUES (?, ?, ?)", + args: ["Alpha@example.com", "sort.test", "2026-03-01 00:00:00"], + }, +]); + +test("waitlist sorting happens before the row limit", async () => { + assert.deepEqual( + (await listDomainSignups("sort.test", 2, "newest")).map((signup) => signup.email), + ["Alpha@example.com", "middle@example.com"], + ); + assert.deepEqual( + (await listDomainSignups("sort.test", 2, "oldest")).map((signup) => signup.email), + ["zulu@example.com", "middle@example.com"], + ); + assert.deepEqual( + (await listDomainSignups("sort.test", 2, "email")).map((signup) => signup.email), + ["Alpha@example.com", "middle@example.com"], + ); +});