g1t/apps/sudo/app/lib/workspaces.ts
| 1 | /** |
| 2 | * sudo is organised around workspaces, as customers know them. Identity |
| 3 | * says which workspaces exist and who owns them; billing says who pays for |
| 4 | * each and where it stands. This joins the two, by slug. No Workers |
| 5 | * imports, so it can be tested under Node. |
| 6 | * |
| 7 | * Billing's account ids (`ws_<slug>`, `ent_…`) are internal: they are used |
| 8 | * to look things up and never shown as a name. |
| 9 | */ |
| 10 | import type { AccountSummary, AdminOwner, AdminWorkspace, Limit, Terms } from "@g1t/contracts"; |
| 11 | |
| 12 | /** Terms every workspace starts on. */ |
| 13 | export const STANDARD_TERMS: Terms = { |
| 14 | kind: "standard", |
| 15 | discountPercent: 0, |
| 16 | ceilingMicros: null, |
| 17 | note: "", |
| 18 | until: null, |
| 19 | setBy: null, |
| 20 | setAt: null, |
| 21 | }; |
| 22 | |
| 23 | export type Enterprise = { id: string; name: string }; |
| 24 | |
| 25 | /** Where a workspace stands with billing. */ |
| 26 | export type WorkspaceBilling = { |
| 27 | /** The enterprise that pays for it, or null when it pays for itself. */ |
| 28 | billedTo: Enterprise | null; |
| 29 | /** The terms it is charged on: its own, or its enterprise's. */ |
| 30 | terms: Terms; |
| 31 | /** |
| 32 | * Its limit this month: its own, or for a workspace on an enterprise the |
| 33 | * enterprise's, which all its workspaces share. Null with no billing |
| 34 | * activity yet. |
| 35 | */ |
| 36 | limit: Limit | null; |
| 37 | /** This workspace's own figures this month (charged, cost) and ever (paid). */ |
| 38 | chargedMicros: number; |
| 39 | costMicros: number; |
| 40 | paidMicros: number; |
| 41 | }; |
| 42 | |
| 43 | export type WorkspaceRow = { |
| 44 | slug: string; |
| 45 | name: string; |
| 46 | /** Null for a workspace billing knows and identity returned nothing for. */ |
| 47 | createdAt: string | null; |
| 48 | owners: AdminOwner[]; |
| 49 | memberCount: number | null; |
| 50 | /** False when only billing knows the slug. */ |
| 51 | known: boolean; |
| 52 | billing: WorkspaceBilling; |
| 53 | }; |
| 54 | |
| 55 | /** The billing side of every workspace billing knows, by slug. */ |
| 56 | export function billingBySlug(accounts: AccountSummary[]): Map<string, WorkspaceBilling> { |
| 57 | const bySlug = new Map<string, WorkspaceBilling>(); |
| 58 | for (const summary of accounts) { |
| 59 | const { account } = summary; |
| 60 | const shares = new Map((summary.byWorkspace ?? []).map((share) => [share.workspace, share])); |
| 61 | if (account.kind === "enterprise") { |
| 62 | for (const slug of account.workspaces) { |
| 63 | const share = shares.get(slug); |
| 64 | bySlug.set(slug, { |
| 65 | billedTo: { id: account.id, name: account.name }, |
| 66 | terms: account.terms, |
| 67 | limit: summary.limit, |
| 68 | chargedMicros: share?.chargedMicros ?? 0, |
| 69 | costMicros: share?.costMicros ?? 0, |
| 70 | paidMicros: share?.paidMicros ?? 0, |
| 71 | }); |
| 72 | } |
| 73 | } else { |
| 74 | const slug = account.workspaces[0] ?? summary.limit.workspace; |
| 75 | // An enterprise's claim on a workspace wins over a stale own account. |
| 76 | if (bySlug.get(slug)?.billedTo) continue; |
| 77 | bySlug.set(slug, { |
| 78 | billedTo: null, |
| 79 | terms: account.terms, |
| 80 | limit: summary.limit, |
| 81 | chargedMicros: summary.chargedMicros, |
| 82 | costMicros: summary.costMicros, |
| 83 | paidMicros: summary.paidMicros, |
| 84 | }); |
| 85 | } |
| 86 | } |
| 87 | return bySlug; |
| 88 | } |
| 89 | |
| 90 | /** A workspace with no billing activity: standard terms, nothing charged. */ |
| 91 | export function noBilling(): WorkspaceBilling { |
| 92 | return { billedTo: null, terms: STANDARD_TERMS, limit: null, chargedMicros: 0, costMicros: 0, paidMicros: 0 }; |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * Every workspace identity listed, in its order, with its billing; then any |
| 97 | * workspace only billing knows. A workspace with no billing activity is |
| 98 | * still listed, on standard terms at $0. |
| 99 | */ |
| 100 | export function joinWorkspaces(workspaces: AdminWorkspace[], accounts: AccountSummary[]): WorkspaceRow[] { |
| 101 | const billing = billingBySlug(accounts); |
| 102 | const rows: WorkspaceRow[] = workspaces.map((workspace) => ({ |
| 103 | slug: workspace.slug, |
| 104 | name: workspace.name, |
| 105 | createdAt: workspace.createdAt, |
| 106 | owners: workspace.owners, |
| 107 | memberCount: workspace.memberCount, |
| 108 | known: true, |
| 109 | billing: billing.get(workspace.slug) ?? noBilling(), |
| 110 | })); |
| 111 | const listed = new Set(rows.map((row) => row.slug)); |
| 112 | for (const [slug, entry] of billing) { |
| 113 | if (listed.has(slug)) continue; |
| 114 | rows.push({ slug, name: slug, createdAt: null, owners: [], memberCount: null, known: false, billing: entry }); |
| 115 | } |
| 116 | return rows; |
| 117 | } |
| 118 | |
| 119 | /** Whether a row matches a search: slug, name, owner, owner's email or enterprise. */ |
| 120 | export function matchesQuery(row: WorkspaceRow, query: string): boolean { |
| 121 | const q = query.trim().toLowerCase(); |
| 122 | if (!q) return true; |
| 123 | const haystack = [ |
| 124 | row.slug, |
| 125 | row.name, |
| 126 | row.billing.billedTo?.name, |
| 127 | ...row.owners.flatMap((owner) => [owner.username, owner.email]), |
| 128 | ]; |
| 129 | return haystack.some((value) => value?.toLowerCase().includes(q)); |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Where an old `/accounts/<id>` link now lives: a workspace's own account |
| 134 | * (`ws_<slug>`, or a bare slug) at its workspace, an enterprise at its own |
| 135 | * page. Null for anything else. |
| 136 | */ |
| 137 | export function legacyAccountPath(id: string): string | null { |
| 138 | const value = id.trim().toLowerCase(); |
| 139 | if (/^ent_[a-z0-9_-]{1,80}$/.test(value)) return `/enterprises/${encodeURIComponent(value)}`; |
| 140 | const slug = value.startsWith("ws_") ? value.slice(3) : value; |
| 141 | if (/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(slug)) return `/workspaces/${encodeURIComponent(slug)}`; |
| 142 | return null; |
| 143 | } |
| 144 | |
| 145 | /** A row's owners' usernames, as one line. */ |
| 146 | export function ownerNames(owners: AdminOwner[]): string { |
| 147 | return owners.map((owner) => owner.username).join(", "); |
| 148 | } |