g1t/apps/sudo/app/lib/invites.ts

117 lines5,200 bytesCodeBlame
1/**
2 * The Invites page's forms and tabs: the waitlist, every invite, grants of
3 * more invites, and a person's invite tree. No Workers imports, so it can
4 * be tested under Node. Identity checks everything again.
5 */
6import type { WaitlistStatus } from "@g1t/contracts";
7
8import type { Parsed } from "./forms.ts";
9
10export const INVITE_TABS = ["waitlist", "invites", "grant", "tree"] as const;
11export type InviteTab = (typeof INVITE_TABS)[number];
12
13export const TAB_LABEL: Record<InviteTab, string> = {
14 waitlist: "Waitlist",
15 invites: "Invites",
16 grant: "Grant & mint",
17 tree: "Invite tree",
18};
19
20export function parseTab(raw: string | null): InviteTab {
21 return (INVITE_TABS as readonly string[]).includes(raw ?? "") ? (raw as InviteTab) : "waitlist";
22}
23
24const STATUSES: (WaitlistStatus | "all")[] = ["waiting", "invited", "dismissed", "all"];
25
26export function parseWaitlistStatus(raw: string | null): WaitlistStatus | "all" {
27 return STATUSES.includes(raw as WaitlistStatus) ? (raw as WaitlistStatus | "all") : "waiting";
28}
29
30/** Where a tab lives, with its search. */
31export function invitesHref(tab: InviteTab, params: Record<string, string | null | undefined> = {}): string {
32 const search = new URLSearchParams({ tab });
33 for (const [key, value] of Object.entries(params)) if (value) search.set(key, value);
34 return `/invites?${search}`;
35}
36
37/** The most one grant gives or takes back; identity holds the same line. */
38export const MAX_GRANT = 1000;
39
40const NAME = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
41
42export type Grant = { target: "user" | "workspace"; name: string; amount: number; note: string };
43
44/** A grant of invites, as typed: who, how many (negative takes back), and why. */
45export function parseGrant(form: { get(name: string): unknown }): Parsed<Grant> {
46 const read = (name: string) => {
47 const value = form.get(name);
48 return typeof value === "string" ? value.trim() : "";
49 };
50 const target = read("target");
51 if (target !== "user" && target !== "workspace") return { ok: false, error: "Choose a person or a workspace." };
52 const name = read("name").replace(/^@/, "").toLowerCase();
53 if (!NAME.test(name)) return { ok: false, error: `“${read("name")}” is not a ${target === "user" ? "username" : "workspace slug"}.` };
54 const raw = read("amount");
55 if (!/^-?\d+$/.test(raw)) return { ok: false, error: "Give a whole number of invites, such as 10, or -5 to take some back." };
56 const amount = Number(raw);
57 if (amount === 0 || Math.abs(amount) > MAX_GRANT) return { ok: false, error: `Grant between 1 and ${MAX_GRANT} invites.` };
58 const note = read("note");
59 if (note.length < 3) return { ok: false, error: "Say why, for the record." };
60 return { ok: true, value: { target, name, amount, note: note.slice(0, 500) } };
61}
62
63/** An address to mint an invite for, or none: anyone with the code. */
64export function parseMintEmail(raw: string): Parsed<string | null> {
65 const email = raw.trim().toLowerCase();
66 if (!email) return { ok: true, value: null };
67 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? { ok: true, value: email } : { ok: false, error: `“${raw}” is not an email address.` };
68}
69
70/** The flash messages the page redirects back with (`?done=`). */
71export const INVITES_DONE: Record<string, string> = {
72 approved: "Approved. The invite is on its way to that address.",
73 dismissed: "Dismissed.",
74 revoked: "Invite revoked. It comes back to whoever it was charged to.",
75 granted: "Granted. It applies at once.",
76};
77
78/** The flash for `?done=` and, after deciding several at once, `?n=` of them. */
79export function doneMessage(done: string | null, n: string | null): string | null {
80 if (!done) return null;
81 const count = Number(n);
82 if (Number.isInteger(count) && count > 1) {
83 if (done === "approved") return `Approved ${count} requests. Each invite is on its way.`;
84 if (done === "dismissed") return `Dismissed ${count} requests.`;
85 }
86 return INVITES_DONE[done] ?? null;
87}
88
89/** The most requests one bulk decision takes: each one is its own call to identity. */
90export const MAX_BULK = 50;
91
92/** The waitlist entries ticked on a bulk form, each once, in order. */
93export function parseIds(form: { getAll(name: string): unknown[] }): Parsed<string[]> {
94 const ids = [
95 ...new Set(
96 form
97 .getAll("ids")
98 .filter((id): id is string => typeof id === "string")
99 .map((id) => id.trim())
100 .filter((id) => /^wl_[0-9a-z]{1,40}$/.test(id)),
101 ),
102 ];
103 if (ids.length === 0) return { ok: false, error: "Tick the requests to decide on first." };
104 if (ids.length > MAX_BULK) return { ok: false, error: `Decide on at most ${MAX_BULK} at a time.` };
105 return { ok: true, value: ids };
106}
107
108/** The most characters an approval's note keeps; identity holds the same line. */
109export const MAX_NOTE = 500;
110
111/** A note for the invite email: trimmed, or none. */
112export function parseNote(raw: string | null | undefined): Parsed<string | null> {
113 const note = (raw ?? "").trim();
114 if (!note) return { ok: true, value: null };
115 if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note to ${MAX_NOTE} characters; it is ${note.length}.` };
116 return { ok: true, value: note };
117}