@cosmicdrift/kumiko-bundled-features 0.224.2 → 0.225.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ // cap-overview — read-only tier/cap-usage visibility per tenant.
2
+ //
3
+ // **What this feature does:**
4
+ // 1. tenant-caps:list query + tenant-cap-list screen — SystemAdmin-only
5
+ // platform-wide table of every tenant's tier, billing status, and
6
+ // usage against a configurable set of caps.
7
+ // 2. caps:usage query + my-caps / platform-tenant-caps dashboards —
8
+ // per-tenant usage cards. TenantAdmin sees their own tenant;
9
+ // SystemAdmin can additionally view any tenant via `tenantId`.
10
+ //
11
+ // **What this feature does NOT do:**
12
+ // - No writes. Reads tier-engine's read_tier_assignments, billing-
13
+ // foundation's read_subscriptions, tenant's read_tenants, plus
14
+ // app-owned usage tables via the caller-supplied `CapSpec` callbacks.
15
+ // - No nav wiring — a separate cut adds `r.nav(...)` entries.
16
+ //
17
+ // **Boot-Dependencies:** tenant, tier-engine, billing-foundation.
18
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { CAP_OVERVIEW_FEATURE } from "./constants";
20
+ import { createCapsUsageQuery } from "./handlers/caps-usage.query";
21
+ import { createTenantCapsListQuery } from "./handlers/tenant-caps-list.query";
22
+ import { tenantOptionsQuery } from "./handlers/tenant-options.query";
23
+ import { CAP_OVERVIEW_I18N } from "./i18n";
24
+ import { createTenantCapListScreen, myCapsScreen, platformTenantCapsScreen } from "./screens";
25
+ import type { CapSpec } from "./types";
26
+
27
+ export type CreateCapOverviewOptions = {
28
+ readonly caps: readonly CapSpec[];
29
+ /** Caps shown as columns on the platform-wide tenant-cap-list screen.
30
+ * Defaults to the first three caps when omitted. */
31
+ readonly listCaps?: readonly string[];
32
+ /** Options for the tenant-cap-list tier facet. Omitted → no facet — the
33
+ * engine itself carries no enumerated tier vocabulary to derive one
34
+ * from (see screens.ts doc). "Filter by tier" was explicitly requested;
35
+ * omitting `tiers` silently drops that filter, not a supported default. */
36
+ readonly tiers?: readonly string[];
37
+ };
38
+
39
+ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): FeatureDefinition {
40
+ if (opts.caps.length === 0) {
41
+ throw new Error("createCapOverviewFeature: `caps` must not be empty.");
42
+ }
43
+ const capIds = new Set(opts.caps.map((cap) => cap.id));
44
+ const listCaps = opts.listCaps ?? opts.caps.slice(0, 3).map((cap) => cap.id);
45
+ for (const id of listCaps) {
46
+ if (!capIds.has(id)) {
47
+ throw new Error(
48
+ `createCapOverviewFeature: listCaps references unknown cap id "${id}" — known ids: ${[...capIds].join(", ")}`,
49
+ );
50
+ }
51
+ }
52
+
53
+ return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
54
+ r.describe(
55
+ "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; TenantAdmin gets their own usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
56
+ );
57
+ r.uiHints({
58
+ displayLabel: "Cap Overview · Tier & Usage Visibility",
59
+ category: "operations",
60
+ recommended: false,
61
+ });
62
+ r.systemScope();
63
+ r.requires("tenant");
64
+ r.requires("tier-engine");
65
+ r.requires("billing-foundation");
66
+
67
+ r.queryHandler(createTenantCapsListQuery(opts.caps, listCaps));
68
+ r.queryHandler(createCapsUsageQuery(opts.caps));
69
+ r.queryHandler(tenantOptionsQuery);
70
+
71
+ r.screen(createTenantCapListScreen(opts.caps, listCaps, opts.tiers));
72
+ r.screen(myCapsScreen);
73
+ r.screen(platformTenantCapsScreen);
74
+
75
+ r.translations({ keys: CAP_OVERVIEW_I18N });
76
+ });
77
+ }
@@ -0,0 +1,86 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ crossTenantOverrideDenied,
6
+ defineQueryHandler,
7
+ type QueryHandlerDefinition,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { z } from "zod";
11
+ import { tierAssignmentEntity } from "../../tier-engine";
12
+ import type { CapSpec, CapUsageWithMeta } from "../types";
13
+ import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math";
14
+
15
+ type TierAssignmentRow = { readonly tenantId: string; readonly tier: string };
16
+
17
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
18
+
19
+ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefinition {
20
+ return defineQueryHandler({
21
+ name: "caps:usage",
22
+ schema: z.object({ tenantId: z.string().min(1).optional() }),
23
+ access: { roles: access.admin },
24
+ handler: async (query, ctx) => {
25
+ if (!ctx.systemDb) {
26
+ throw new InternalError({
27
+ message: "cap-overview:query:caps:usage requires ctx.systemDb — is r.systemScope() set?",
28
+ });
29
+ }
30
+ const override = query.payload.tenantId;
31
+ const overrideDenied = crossTenantOverrideDenied(
32
+ query.user,
33
+ override,
34
+ "cap-overview.errors.tenantOverrideRequiresSystemAdmin",
35
+ );
36
+ if (overrideDenied) throw overrideDenied;
37
+
38
+ const targetTenantId = override ?? query.user.tenantId;
39
+ // Both branches return the SAME unfiltered system-mode db —
40
+ // assertTenantMatch is a self-check on the caller, not a query
41
+ // filter (see tenant-db.ts). Every read below carries its own
42
+ // explicit `tenantId` WHERE regardless of which branch ran.
43
+ const db =
44
+ override !== undefined
45
+ ? ctx.systemDb.acknowledgeCrossTenant(
46
+ `cap-overview:caps:usage — SystemAdmin cross-tenant read for tenant ${targetTenantId}`,
47
+ )
48
+ : ctx.systemDb.assertTenantMatch(query.user.tenantId);
49
+
50
+ const assignmentRows = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {
51
+ tenantId: [targetTenantId],
52
+ });
53
+ // Defense-in-depth on the own-tenant path only: assertRowsTenant
54
+ // checks rows against the CALLER's own tenantId, which is only
55
+ // meaningful when the caller is reading their own tenant — on the
56
+ // SystemAdmin override path the target tenant legitimately differs
57
+ // from the caller's own tenantId, so the check would misfire there.
58
+ const checkedRows =
59
+ override === undefined
60
+ ? ctx.systemDb.assertRowsTenant(assignmentRows, "tenantId")
61
+ : assignmentRows;
62
+ const tier = checkedRows[0]?.tier ?? "";
63
+
64
+ const rows: CapUsageWithMeta[] = await Promise.all(
65
+ caps.map(async (cap) => {
66
+ const used = await cap.usage(db, targetTenantId);
67
+ const limit = cap.limit(tier);
68
+ const fraction = computeFraction(used, limit);
69
+ return {
70
+ id: cap.id,
71
+ label: cap.label,
72
+ used,
73
+ limit,
74
+ fraction,
75
+ tone: computeTone(fraction),
76
+ percent: Math.round(computeUnclampedFraction(used, limit) * 100),
77
+ ...(cap.icon !== undefined && { icon: cap.icon }),
78
+ ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
79
+ };
80
+ }),
81
+ );
82
+
83
+ return { rows };
84
+ },
85
+ });
86
+ }
@@ -0,0 +1,224 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { buildEntityTable, decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
3
+ import { definePagedQueryHandler, MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { z } from "zod";
6
+ import { subscriptionsProjectionTable } from "../../billing-foundation";
7
+ import { tenantTable } from "../../tenant";
8
+ import { tierAssignmentEntity } from "../../tier-engine";
9
+ import { capFieldName } from "../constants";
10
+ import type { CapSpec, CapUsage } from "../types";
11
+ import { computeFraction } from "../usage-math";
12
+
13
+ type TenantRow = { readonly id: string; readonly name: string };
14
+ type TierAssignmentRow = {
15
+ readonly tenantId: string;
16
+ readonly tier: string;
17
+ readonly source: string | null;
18
+ };
19
+ type SubscriptionRow = {
20
+ readonly tenantId: string;
21
+ readonly providerName: string;
22
+ readonly status: string;
23
+ };
24
+
25
+ type TenantCapsListRow = {
26
+ readonly tenantId: string;
27
+ readonly name: string;
28
+ readonly tier: string;
29
+ readonly billing: string;
30
+ readonly [capField: string]: unknown;
31
+ };
32
+
33
+ // tier-resolver.ts rebuilds the same drizzle table locally rather than
34
+ // importing a pre-built one — tier-engine's public barrel only exports the
35
+ // entity, not a built table (see tier-engine/tier-resolver.ts).
36
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
37
+
38
+ function billingLabel(
39
+ subscription: SubscriptionRow | undefined,
40
+ tierSource: string | null,
41
+ ): string {
42
+ if (subscription) return `${subscription.providerName} · ${subscription.status}`;
43
+ if (tierSource === "manual") return "manual";
44
+ return "—";
45
+ }
46
+
47
+ const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
48
+ type FilterOp = z.infer<typeof FILTER_OP>;
49
+
50
+ const SORTABLE_FIELDS = ["name", "tier", "billing"] as const;
51
+ type SortableField = (typeof SORTABLE_FIELDS)[number];
52
+
53
+ function isSortableField(field: string): field is SortableField {
54
+ return (SORTABLE_FIELDS as readonly string[]).includes(field);
55
+ }
56
+
57
+ // tier is a plain string with no defined ordering — lt/gt have no meaning
58
+ // on it and are rejected rather than silently treated as "no filter".
59
+ function assertSupportedTierFilterOp(op: FilterOp): void {
60
+ if (op === "lt" || op === "gt") {
61
+ throw new ValidationError({
62
+ fields: [
63
+ {
64
+ path: "filters",
65
+ code: "unsupported_op",
66
+ i18nKey: "cap-overview.errors.tierFilterOpUnsupported",
67
+ params: { op },
68
+ },
69
+ ],
70
+ });
71
+ }
72
+ }
73
+
74
+ function matchesTierFilter(
75
+ tier: string,
76
+ filter: { readonly op: FilterOp; readonly value: unknown } | undefined,
77
+ ): boolean {
78
+ if (filter === undefined) return true;
79
+ switch (filter.op) {
80
+ case "eq":
81
+ return tier === filter.value;
82
+ case "ne":
83
+ return tier !== filter.value;
84
+ case "in":
85
+ return Array.isArray(filter.value) && filter.value.includes(tier);
86
+ case "lt":
87
+ case "gt":
88
+ // unreachable in practice — assertSupportedTierFilterOp rejects these before this runs
89
+ return true;
90
+ }
91
+ }
92
+
93
+ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: readonly string[]) {
94
+ const listedCaps = caps.filter((cap) => listCaps.includes(cap.id));
95
+
96
+ return definePagedQueryHandler({
97
+ name: "tenant-caps:list",
98
+ schema: z.object({
99
+ cursor: z.string().optional(),
100
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
101
+ sort: z.string().optional(),
102
+ sortDirection: z.enum(["asc", "desc"]).optional(),
103
+ search: z.string().optional(),
104
+ filters: z
105
+ .array(z.object({ field: z.string(), op: FILTER_OP, value: z.unknown() }))
106
+ .optional(),
107
+ totalCount: z.boolean().optional(),
108
+ }),
109
+ access: { roles: ["SystemAdmin"] },
110
+ handler: async (query, ctx) => {
111
+ if (!ctx.systemDb) {
112
+ throw new InternalError({
113
+ message:
114
+ "cap-overview:query:tenant-caps:list requires ctx.systemDb — is r.systemScope() set?",
115
+ });
116
+ }
117
+ const db = ctx.systemDb.acknowledgeCrossTenant(
118
+ "cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview",
119
+ );
120
+
121
+ const tenants = await selectMany<TenantRow>(db, tenantTable, {});
122
+ const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
123
+ const subscriptions = await selectMany<SubscriptionRow>(db, subscriptionsProjectionTable, {});
124
+
125
+ const assignmentByTenant = new Map(assignments.map((row) => [row.tenantId, row]));
126
+ const subscriptionByTenant = new Map(subscriptions.map((row) => [row.tenantId, row]));
127
+
128
+ // Case-insensitive substring search has no `ilike` operator in the
129
+ // query builder (only `like`, SQL-LIKE case-sensitive) — filtered here
130
+ // in JS, same as tenant/team-list.query.ts's search.
131
+ const search = query.payload.search?.trim().toLowerCase();
132
+ const filters = query.payload.filters ?? [];
133
+ const tierFilter = filters.find((f) => f.field === "tier");
134
+ if (tierFilter !== undefined) assertSupportedTierFilterOp(tierFilter.op);
135
+
136
+ const merged = tenants
137
+ .filter(
138
+ (tenant) =>
139
+ search === undefined || search === "" || tenant.name.toLowerCase().includes(search),
140
+ )
141
+ .map((tenant) => {
142
+ const assignment = assignmentByTenant.get(tenant.id);
143
+ return {
144
+ tenantId: tenant.id,
145
+ name: tenant.name,
146
+ tier: assignment?.tier ?? "",
147
+ source: assignment?.source ?? null,
148
+ billing: billingLabel(subscriptionByTenant.get(tenant.id), assignment?.source ?? null),
149
+ };
150
+ })
151
+ .filter((row) => matchesTierFilter(row.tier, tierFilter));
152
+
153
+ const sortField = query.payload.sort ?? "name";
154
+ if (!isSortableField(sortField)) {
155
+ throw new ValidationError({
156
+ fields: [
157
+ {
158
+ path: "sort",
159
+ code: "unsupported_field",
160
+ i18nKey: "cap-overview.errors.sortFieldUnsupported",
161
+ params: { field: sortField },
162
+ },
163
+ ],
164
+ });
165
+ }
166
+ const sortDirection = query.payload.sortDirection ?? "asc";
167
+ const directionMultiplier = sortDirection === "asc" ? 1 : -1;
168
+ const sorted = [...merged].sort(
169
+ (a, b) => a[sortField].localeCompare(b[sortField]) * directionMultiplier,
170
+ );
171
+
172
+ // ponytail: base64-wrapped offset, not a SQL keyset — rows come from a
173
+ // JS merge of three tables (tenant/tier-assignment/subscription), same
174
+ // rationale as tenant/team-list.query.ts. Holds up to tier-engine's
175
+ // documented "single-pass scan" scale (a few thousand tenants); past
176
+ // that the fix is a real combined read-projection, not a cursor
177
+ // bolted onto this merge.
178
+ const offset = query.payload.cursor ? Number(decodeCursor(query.payload.cursor)) : 0;
179
+ const limit = query.payload.limit;
180
+ const page = sorted.slice(offset, offset + limit);
181
+
182
+ // N+1 avoidance: usage is computed only for this page's tenant ids,
183
+ // never for the full tenant set.
184
+ const pageTenantIds = page.map((row) => row.tenantId);
185
+ const usageByCap = new Map<string, Map<string, number>>();
186
+ for (const cap of listedCaps) {
187
+ if (cap.usageBatch) {
188
+ usageByCap.set(cap.id, await cap.usageBatch(db, pageTenantIds));
189
+ } else {
190
+ const perTenant = new Map<string, number>();
191
+ for (const tenantId of pageTenantIds) {
192
+ perTenant.set(tenantId, await cap.usage(db, tenantId));
193
+ }
194
+ usageByCap.set(cap.id, perTenant);
195
+ }
196
+ }
197
+
198
+ const rows: TenantCapsListRow[] = page.map((row) => {
199
+ const capFields: Record<string, CapUsage> = {};
200
+ for (const cap of listedCaps) {
201
+ const used = usageByCap.get(cap.id)?.get(row.tenantId) ?? 0;
202
+ const limit = cap.limit(row.tier);
203
+ capFields[capFieldName(cap.id)] = { used, limit, fraction: computeFraction(used, limit) };
204
+ }
205
+ return {
206
+ tenantId: row.tenantId,
207
+ name: row.name,
208
+ tier: row.tier,
209
+ billing: row.billing,
210
+ ...capFields,
211
+ };
212
+ });
213
+
214
+ const nextCursor =
215
+ offset + limit < sorted.length ? encodeCursor(String(offset + limit)) : null;
216
+
217
+ return {
218
+ rows,
219
+ nextCursor,
220
+ ...(query.payload.totalCount === true && { total: merged.length }),
221
+ };
222
+ },
223
+ });
224
+ }
@@ -0,0 +1,33 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { z } from "zod";
5
+ import { tenantTable } from "../../tenant";
6
+
7
+ type TenantRow = { readonly id: string; readonly name: string };
8
+
9
+ export const tenantOptionsQuery = defineQueryHandler({
10
+ name: "tenant-options",
11
+ schema: z.object({}),
12
+ access: { roles: ["SystemAdmin"] },
13
+ handler: async (_query, ctx) => {
14
+ if (!ctx.systemDb) {
15
+ throw new InternalError({
16
+ message:
17
+ "cap-overview:query:tenant-options requires ctx.systemDb — is r.systemScope() set?",
18
+ });
19
+ }
20
+ const db = ctx.systemDb.acknowledgeCrossTenant(
21
+ "cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options",
22
+ );
23
+ const tenants = await selectMany<TenantRow>(
24
+ db,
25
+ tenantTable,
26
+ {},
27
+ {
28
+ orderBy: { col: "name", direction: "asc" },
29
+ },
30
+ );
31
+ return { rows: tenants.map((tenant) => ({ value: tenant.id, label: tenant.name })) };
32
+ },
33
+ });
@@ -0,0 +1,25 @@
1
+ type LocalizedString = { readonly en: string };
2
+
3
+ export const CAP_OVERVIEW_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:tenant-cap-list.title": { en: "Tenant caps" },
5
+ "screen:my-caps.title": { en: "My usage" },
6
+ "screen:platform-tenant-caps.title": { en: "Tenant dashboard" },
7
+ "cap-overview.list.col.name": { en: "Tenant" },
8
+ "cap-overview.list.col.tier": { en: "Tier" },
9
+ "cap-overview.list.col.billing": { en: "Billing" },
10
+ "cap-overview.list.filter.tier": { en: "Tier" },
11
+ "cap-overview.list.action.open": { en: "Open dashboard" },
12
+ "cap-overview.platform.filter.tenant": { en: "Tenant" },
13
+ "cap-overview.cards.empty": { en: "No caps configured for this tenant." },
14
+ "cap-overview.cards.loading": { en: "Loading usage…" },
15
+ "cap-overview.errors.progressPrimitiveMissing": {
16
+ en: "Usage bar unavailable — Progress primitive is not registered.",
17
+ },
18
+ "cap-overview.errors.sortFieldUnsupported": { en: "Sorting by this field is not supported." },
19
+ "cap-overview.errors.tenantOverrideRequiresSystemAdmin": {
20
+ en: "Only SystemAdmin may query another tenant's data.",
21
+ },
22
+ "cap-overview.errors.tierFilterOpUnsupported": {
23
+ en: "This filter operator is not supported for tier.",
24
+ },
25
+ };
@@ -0,0 +1,20 @@
1
+ // Public API of the cap-overview bundled-feature.
2
+
3
+ export {
4
+ CAP_CARDS_PANEL_COMPONENT,
5
+ CAP_OVERVIEW_FEATURE,
6
+ CAP_USAGE_CELL_COMPONENT,
7
+ CapOverviewQueries,
8
+ capFieldName,
9
+ MY_CAPS_SCREEN_ID,
10
+ PLATFORM_TENANT_CAPS_SCREEN_ID,
11
+ TENANT_CAP_LIST_SCREEN_ID,
12
+ } from "./constants";
13
+ export { type CreateCapOverviewOptions, createCapOverviewFeature } from "./feature";
14
+ export {
15
+ createTenantCapListScreen,
16
+ myCapsScreen,
17
+ platformTenantCapsScreen,
18
+ } from "./screens";
19
+ export type { CapSpec, CapUsage, CapUsageTone, CapUsageWithMeta } from "./types";
20
+ export { computeFraction, computeTone } from "./usage-math";
@@ -0,0 +1,101 @@
1
+ import { access, type ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ CAP_CARDS_PANEL_COMPONENT,
4
+ CAP_USAGE_CELL_COMPONENT,
5
+ CapOverviewQueries,
6
+ capFieldName,
7
+ MY_CAPS_SCREEN_ID,
8
+ PLATFORM_TENANT_CAPS_SCREEN_ID,
9
+ TENANT_CAP_LIST_SCREEN_ID,
10
+ } from "./constants";
11
+ import type { CapSpec } from "./types";
12
+
13
+ // `tiers` has no room for a separate per-tier display label (it's just
14
+ // `readonly string[]`) — tier-engine itself has no enumerated tier list to
15
+ // derive one from either (it's generic over tier values, see tier-engine's
16
+ // own doc comment). The tier string is used verbatim as the facet option
17
+ // label, same as how tier values already surface untranslated elsewhere
18
+ // (tier-engine's TierMap keys).
19
+ export function createTenantCapListScreen(
20
+ caps: readonly CapSpec[],
21
+ listCaps: readonly string[],
22
+ tiers?: readonly string[],
23
+ ): ScreenDefinition {
24
+ const listedCaps = caps.filter((cap) => listCaps.includes(cap.id));
25
+
26
+ return {
27
+ id: TENANT_CAP_LIST_SCREEN_ID,
28
+ type: "projectionList",
29
+ query: CapOverviewQueries.tenantCapsList,
30
+ columns: [
31
+ { field: "name", label: "cap-overview.list.col.name" },
32
+ { field: "tier", label: "cap-overview.list.col.tier" },
33
+ { field: "billing", label: "cap-overview.list.col.billing" },
34
+ ...listedCaps.map((cap) => ({
35
+ field: capFieldName(cap.id),
36
+ label: cap.label,
37
+ renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
38
+ })),
39
+ ],
40
+ searchable: true,
41
+ ...(tiers !== undefined &&
42
+ tiers.length > 0 && {
43
+ facets: [
44
+ {
45
+ field: "tier",
46
+ type: "select" as const,
47
+ label: "cap-overview.list.filter.tier",
48
+ options: tiers.map((tier) => ({ value: tier, label: tier })),
49
+ },
50
+ ],
51
+ }),
52
+ // params.map's key ("tenantId") must match platform-tenant-caps's own
53
+ // `filter.id` — that's how useFilterParams (dashboard-body.tsx) seeds
54
+ // the tenant filter from the URL on arrival, landing straight on the
55
+ // clicked tenant's dashboard instead of an empty one (boot-validated).
56
+ rowActions: [
57
+ {
58
+ kind: "navigate" as const,
59
+ id: "open-dashboard",
60
+ label: "cap-overview.list.action.open",
61
+ screen: PLATFORM_TENANT_CAPS_SCREEN_ID,
62
+ params: { map: { tenantId: "tenantId" } },
63
+ rowClick: true,
64
+ },
65
+ ],
66
+ defaultSort: { field: "name", dir: "asc" },
67
+ access: { roles: ["SystemAdmin"] },
68
+ };
69
+ }
70
+
71
+ export const myCapsScreen: ScreenDefinition = {
72
+ id: MY_CAPS_SCREEN_ID,
73
+ type: "dashboard",
74
+ panels: [
75
+ {
76
+ kind: "custom",
77
+ id: "cap-cards",
78
+ component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
79
+ },
80
+ ],
81
+ access: { roles: access.admin },
82
+ };
83
+
84
+ export const platformTenantCapsScreen: ScreenDefinition = {
85
+ id: PLATFORM_TENANT_CAPS_SCREEN_ID,
86
+ type: "dashboard",
87
+ panels: [
88
+ {
89
+ kind: "custom",
90
+ id: "cap-cards",
91
+ component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
92
+ },
93
+ ],
94
+ filter: {
95
+ id: "tenantId",
96
+ label: "cap-overview.platform.filter.tenant",
97
+ kind: "select",
98
+ optionsQuery: CapOverviewQueries.tenantOptions,
99
+ },
100
+ access: { roles: ["SystemAdmin"] },
101
+ };
@@ -0,0 +1,51 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+
4
+ // Closed vocabulary for the dashboard cards' icon chip — cap-cards-panel.tsx
5
+ // holds the matching lucide-react lookup. Small on purpose (YAGNI): extend
6
+ // both together when a cap needs a shape not covered here.
7
+ export type CapIconKey = "file" | "hash" | "database" | "users" | "mail" | "gauge";
8
+
9
+ // Consumer-owned cap definition. `usage`/`usageBatch` receive the SAME
10
+ // unfiltered system-mode TenantDb the handler itself reads through — the
11
+ // callback is responsible for scoping its own app-owned tables by tenantId,
12
+ // same obligation the rest of this feature carries (see feature.ts SECURITY
13
+ // note).
14
+ export type CapSpec = {
15
+ readonly id: string;
16
+ readonly label: string;
17
+ readonly limit: (tier: string) => number;
18
+ readonly usage: (db: TenantDb, tenantId: TenantId) => Promise<number>;
19
+ // Batched usage lookup for the tenant-caps:list screen — called once per
20
+ // cap with the current page's tenant ids instead of once per (cap, row).
21
+ // Optional: falls back to per-row `usage()` when absent.
22
+ readonly usageBatch?: (
23
+ db: TenantDb,
24
+ tenantIds: readonly TenantId[],
25
+ ) => Promise<Map<TenantId, number>>;
26
+ readonly unit?: "count" | "mb" | "tokens";
27
+ /** Icon chip on the dashboard cards. Optional — omitted renders the card
28
+ * without an icon, same as before this field existed. */
29
+ readonly icon?: CapIconKey;
30
+ /** Icon-chip accent — same raw-CSS-value contract as DashboardStatPanel's
31
+ * `accentColor` (packages/types/src/screen.ts): a theme token
32
+ * (`var(--color-status-ok)`), not a literal palette hex. Optional. */
33
+ readonly accentColor?: string;
34
+ };
35
+
36
+ export type CapUsageTone = "default" | "warn" | "danger";
37
+
38
+ export type CapUsage = {
39
+ readonly used: number;
40
+ readonly limit: number;
41
+ readonly fraction: number;
42
+ };
43
+
44
+ export type CapUsageWithMeta = CapUsage & {
45
+ readonly id: string;
46
+ readonly label: string;
47
+ readonly tone: CapUsageTone;
48
+ readonly percent: number;
49
+ readonly icon?: CapIconKey;
50
+ readonly accentColor?: string;
51
+ };
@@ -0,0 +1,32 @@
1
+ // @runtime client
2
+ // Pure math with no runtime deps — both the query handlers (runtime → client
3
+ // is allowed by the compat matrix) and cap-usage-bar.tsx (client → client)
4
+ // import it.
5
+ import type { CapUsageTone } from "./types";
6
+
7
+ const WARN_THRESHOLD = 0.8;
8
+ const DANGER_THRESHOLD = 1;
9
+
10
+ // limit <= 0 means "not part of this tier" — 0, never NaN/Infinity.
11
+ function rawFraction(used: number, limit: number): number {
12
+ if (limit <= 0) return 0;
13
+ const fraction = used / limit;
14
+ return Number.isFinite(fraction) ? fraction : 0;
15
+ }
16
+
17
+ export function computeFraction(used: number, limit: number): number {
18
+ return Math.min(1, Math.max(0, rawFraction(used, limit)));
19
+ }
20
+
21
+ // Unclamped — for display of the over-limit case (e.g. "140%"). Bar width
22
+ // and tone still use the clamped computeFraction; only percent shows the
23
+ // real ratio.
24
+ export function computeUnclampedFraction(used: number, limit: number): number {
25
+ return rawFraction(used, limit);
26
+ }
27
+
28
+ export function computeTone(fraction: number): CapUsageTone {
29
+ if (fraction >= DANGER_THRESHOLD) return "danger";
30
+ if (fraction >= WARN_THRESHOLD) return "warn";
31
+ return "default";
32
+ }