pr_01m47d24b0e6n91zwymwxg0vpx/apps/sudo/app/lib/workspaces.ts

136 lines4,996 bytesCodeBlame
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 */
10import type { AccountSummary, AdminOwner, AdminWorkspace, Limit, Terms } from "@g1t/contracts";
11
12/** Terms every workspace starts on. */
13export 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
23export type Enterprise = { id: string; name: string };
24
25/** Where a workspace stands with billing. */
26export 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
43export 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. */
53export 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. */
88export 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 */
98export 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
110export const PAGE_SIZE = 50;
111
112/** One page of a list: the page asked for, kept within the pages there are. */
113export 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 */
125export 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. */
134export function ownerNames(owners: AdminOwner[]): string {
135 return owners.map((owner) => owner.username).join(", ");
136}