g1t/apps/sudo/app/lib/forms.ts
| 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 | } |