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 | createdAt: string; |
| 47 | owners: AdminOwner[]; |
| 48 | memberCount: number; |
| 49 | billing: WorkspaceBilling; |
| 50 | }; |
| 51 | |
| 52 | /** The billing side of every workspace billing knows, by slug. */ |
| 53 | export function billingBySlug(accounts: AccountSummary[]): Map<string, WorkspaceBilling> { |
| 54 | const bySlug = new Map<string, WorkspaceBilling>(); |
| 55 | for (const summary of accounts) { |
| 56 | const { account } = summary; |
| 57 | const shares = new Map((summary.byWorkspace ?? []).map((share) => [share.workspace, share])); |
| 58 | if (account.kind === "enterprise") { |
| 59 | for (const slug of account.workspaces) { |
| 60 | const share = shares.get(slug); |
| 61 | bySlug.set(slug, { |
| 62 | billedTo: { id: account.id, name: account.name }, |
| 63 | terms: account.terms, |
| 64 | limit: summary.limit, |
| 65 | chargedMicros: share?.chargedMicros ?? 0, |
| 66 | costMicros: share?.costMicros ?? 0, |
| 67 | paidMicros: share?.paidMicros ?? 0, |
| 68 | }); |
| 69 | } |
| 70 | } else { |
| 71 | const slug = account.workspaces[0] ?? summary.limit.workspace; |
| 72 | // An enterprise's claim on a workspace wins over a stale own account. |
| 73 | if (bySlug.get(slug)?.billedTo) continue; |
| 74 | bySlug.set(slug, { |
| 75 | billedTo: null, |
| 76 | terms: account.terms, |
| 77 | limit: summary.limit, |
| 78 | chargedMicros: summary.chargedMicros, |
| 79 | costMicros: summary.costMicros, |
| 80 | paidMicros: summary.paidMicros, |
| 81 | }); |
| 82 | } |
| 83 | } |
| 84 | return bySlug; |
| 85 | } |
| 86 | |
| 87 | /** A workspace with no billing activity: standard terms, nothing charged. */ |
| 88 | export function noBilling(): WorkspaceBilling { |
| 89 | return { billedTo: null, terms: STANDARD_TERMS, limit: null, chargedMicros: 0, costMicros: 0, paidMicros: 0 }; |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * The workspaces identity listed, in its order, each with its billing. Only |
| 94 | * these: an enterprise's other workspaces, which billing returns with it, |
| 95 | * are not added. A workspace with no billing activity is still listed, on |
| 96 | * standard terms at $0. |
| 97 | */ |
| 98 | export function joinWorkspaces(workspaces: AdminWorkspace[], accounts: AccountSummary[]): WorkspaceRow[] { |
| 99 | const billing = billingBySlug(accounts); |
| 100 | return workspaces.map((workspace) => ({ |
| 101 | slug: workspace.slug, |
| 102 | name: workspace.name, |
| 103 | createdAt: workspace.createdAt, |
| 104 | owners: workspace.owners, |
| 105 | memberCount: workspace.memberCount, |
| 106 | billing: billing.get(workspace.slug) ?? noBilling(), |
| 107 | })); |
| 108 | } |
| 109 | |
| 110 | export const PAGE_SIZE = 50; |
| 111 | |
| 112 | /** One page of a list: the page asked for, kept within the pages there are. */ |
| 113 | export function paginate<T>(items: T[], requested: string | null, size = PAGE_SIZE) { |
| 114 | const pages = Math.max(1, Math.ceil(items.length / size)); |
| 115 | const asked = Number.parseInt(requested ?? "1", 10); |
| 116 | const page = Number.isFinite(asked) ? Math.min(Math.max(asked, 1), pages) : 1; |
| 117 | return { page, pages, items: items.slice((page - 1) * size, page * size) }; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Where an old `/accounts/<id>` link now lives: a workspace's own account |
| 122 | * (`ws_<slug>`, or a bare slug) at its workspace, an enterprise at its own |
| 123 | * page. Null for anything else. |
| 124 | */ |
| 125 | export function legacyAccountPath(id: string): string | null { |
| 126 | const value = id.trim().toLowerCase(); |
| 127 | if (/^ent_[a-z0-9_-]{1,80}$/.test(value)) return `/enterprises/${encodeURIComponent(value)}`; |
| 128 | const slug = value.startsWith("ws_") ? value.slice(3) : value; |
| 129 | if (/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(slug)) return `/workspaces/${encodeURIComponent(slug)}`; |
| 130 | return null; |
| 131 | } |
| 132 | |
| 133 | /** A row's owners' usernames, as one line. */ |
| 134 | export function ownerNames(owners: AdminOwner[]): string { |
| 135 | return owners.map((owner) => owner.username).join(", "); |
| 136 | } |