flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

76 lines3,492 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};