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/forms.ts

118 lines4,694 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing accounts, terms and enterprises; g1t is no longer free1/**
2 * Reading sudo's forms. Everything typed is checked here before it goes
3 * to the billing service, which checks it again.
4 */
5import type { Terms } from "@g1t/contracts";
6
7import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts";
8
9/** A workspace slug, as identity allows them (GitHub's rules). */
10const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
11/** An account id (`ws_<slug>`, `ent_…`) or a workspace slug. */
12const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
13
14/** The most one credit can be, against a slipped finger. */
15export const MAX_CREDIT_MICROS = 10_000 * MICROS_PER_DOLLAR;
16const MAX_NOTE = 500;
17
18export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
19
20export function text(form: FormData, name: string): string {
21 const value = form.get(name);
22 return typeof value === "string" ? value.trim() : "";
23}
24
25/** The form's own fields, to carry through a confirmation step. */
26export function fields(form: FormData, ...names: string[]): Record<string, string> {
27 return Object.fromEntries(names.map((name) => [name, text(form, name)]));
28}
29
30export function isSlug(value: string): boolean {
31 return SLUG.test(value);
32}
33
34export function isAccountId(value: string): boolean {
35 return ACCOUNT_ID.test(value);
36}
37
38export function parseSlug(raw: string): Parsed<string> {
39 const slug = raw.trim().toLowerCase();
40 return isSlug(slug) ? { ok: true, value: slug } : { ok: false, error: `“${raw}” is not a workspace slug.` };
41}
42
43/** Slugs separated by commas, spaces or lines; each once. */
44export function parseSlugList(raw: string): Parsed<string[]> {
45 const slugs: string[] = [];
46 for (const part of raw.split(/[\s,]+/).filter(Boolean)) {
47 const slug = parseSlug(part);
48 if (!slug.ok) return slug;
49 if (!slugs.includes(slug.value)) slugs.push(slug.value);
50 }
51 return { ok: true, value: slugs };
52}
53
54export function parseNote(raw: string): Parsed<string> {
55 if (!raw) return { ok: false, error: "A note is required: say why, for whoever looks next." };
56 if (raw.length > MAX_NOTE) return { ok: false, error: `Keep the note under ${MAX_NOTE} characters.` };
57 return { ok: true, value: raw };
58}
59
60/**
61 * Terms from the terms form. Standard clears everything else; a ceiling
62 * applies to comped and custom; a discount to custom only. An end date is
63 * a day, and the terms last to its end, UTC.
64 */
65export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> {
66 const kind = text(form, "kind");
67 if (kind !== "standard" && kind !== "comped" && kind !== "custom") return { ok: false, error: "Choose standard, comped or custom terms." };
68 const note = parseNote(text(form, "note"));
69 if (!note.ok) return note;
70
71 let discountPercent = 0;
72 if (kind === "custom") {
73 const raw = text(form, "discount");
74 if (raw !== "") {
75 if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." };
76 discountPercent = Number(raw);
77 }
78 }
79
80 let ceilingMicros: number | null = null;
81 if (kind !== "standard") {
82 const raw = text(form, "ceiling");
83 if (raw !== "") {
84 const micros = parseDollars(raw);
85 if (micros == null) return { ok: false, error: "The ceiling is a dollar amount, such as 250 or 1,000.00." };
86 ceilingMicros = micros;
87 }
88 }
89 if (kind === "custom" && discountPercent === 0 && ceilingMicros == null) {
90 return { ok: false, error: "Custom terms need a discount, a ceiling, or both." };
91 }
92
93 let until: string | null = null;
94 if (kind !== "standard") {
95 const raw = text(form, "until");
96 if (raw !== "") {
97 const end = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T23:59:59Z`) : null;
98 if (!end || Number.isNaN(end.getTime()) || end.toISOString().slice(0, 10) !== raw) {
99 return { ok: false, error: "The end date is not a date." };
100 }
101 if (end.getTime() <= now.getTime()) return { ok: false, error: "The end date has to be in the future." };
102 until = end.toISOString().replace(".000Z", "Z");
103 }
104 }
105
106 return {
107 ok: true,
108 value: { kind, discountPercent, ceilingMicros, note: note.value, until, setBy: by, setAt: now.toISOString() },
109 };
110}
111
112/** A credit's amount: more than nothing, and no more than the cap. */
113export function parseCredit(raw: string): Parsed<number> {
114 const micros = parseDollars(raw);
115 if (micros == null || micros <= 0) return { ok: false, error: "The amount is dollars and cents, more than zero, such as 25 or 120.50." };
116 if (micros > MAX_CREDIT_MICROS) return { ok: false, error: "One credit is at most $10,000. Issue more than one if it really is more." };
117 return { ok: true, value: micros };
118}