g1t/apps/sudo/app/lib/forms.ts
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 free | 1 | /** |
| 2 | * Reading sudo's forms. Everything typed is checked here before it goes | |
| 3 | * to the billing service, which checks it again. | |
| 4 | */ | |
| 5 | import type { Terms } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts"; | |
| 8 | ||
| 9 | /** A workspace slug, as identity allows them (GitHub's rules). */ | |
| 10 | const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 11 | /** An account id (`ws_<slug>`, `ent_…`) or a workspace slug. */ | |
| 12 | const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/; | |
| 13 | ||
| 14 | /** The most one credit can be, against a slipped finger. */ | |
| 15 | export const MAX_CREDIT_MICROS = 10_000 * MICROS_PER_DOLLAR; | |
| 16 | const MAX_NOTE = 500; | |
| 17 | ||
| 18 | export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 19 | ||
| 20 | export 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. */ | |
| 26 | export function fields(form: FormData, ...names: string[]): Record<string, string> { | |
| 27 | return Object.fromEntries(names.map((name) => [name, text(form, name)])); | |
| 28 | } | |
| 29 | ||
| 30 | export function isSlug(value: string): boolean { | |
| 31 | return SLUG.test(value); | |
| 32 | } | |
| 33 | ||
| 34 | export function isAccountId(value: string): boolean { | |
| 35 | return ACCOUNT_ID.test(value); | |
| 36 | } | |
| 37 | ||
| 38 | export 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. */ | |
| 44 | export 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 | ||
| 54 | export 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 | */ | |
| 65 | export 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. */ | |
| 113 | export 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 | } |