From 54e0e800d6064c808e9c2f39cbf3c5bcd27098c8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 18:13:20 -0700 Subject: [PATCH 1/3] fix(insights): show member profile photos with the canonical avatar --- .../[organizationId]/usage/route.test.ts | 15 +++++- .../components/activity-table.tsx | 2 +- .../components/usage-consumers.tsx | 21 ++++---- .../components/usage-member-avatar.tsx | 24 --------- .../components/usage-monitoring.tsx | 6 --- .../components/usage-top-cards.tsx | 5 -- .../api/contracts/organization-activity.ts | 2 + .../lib/api/contracts/organization-usage.ts | 25 +++++++--- .../get-organization-usage-breakdown.ts | 18 +++++-- .../get-organization-usage-overview.test.ts | 20 ++++---- .../get-organization-usage-overview.ts | 15 +----- .../organization-usage-use-cases.test.ts | 2 +- .../core/organization-activity-queries.ts | 5 +- .../organization-activity.postgres.test.ts | 6 ++- .../billing/core/usage-analytics-queries.ts | 49 ++++++++----------- .../tools/server/settings-collections.test.ts | 17 +++++++ .../tools/server/settings-collections.ts | 8 +++ .../tools/server/settings-operations.ts | 13 +++-- 18 files changed, 134 insertions(+), 119 deletions(-) delete mode 100644 apps/sim/ee/organization-usage/components/usage-member-avatar.tsx diff --git a/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts b/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts index 0336f0878f4..3c83ec6ff3f 100644 --- a/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts +++ b/apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ totals: vi.fn(), series: vi.fn(), breakdown: vi.fn(), + entities: vi.fn(), logs: vi.fn(), Unauthenticated: class extends Error {}, })) @@ -58,7 +59,7 @@ vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({ readUsageTotals: mocks.totals, readUsageTimeSeries: mocks.series, readUsageGroups: mocks.breakdown, - readUsageEntityNames: vi.fn().mockResolvedValue(new Map()), + readUsageEntities: mocks.entities, })) vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingEntityUsageLogs: mocks.logs })) @@ -134,6 +135,7 @@ beforeEach(() => { mocks.totals.mockResolvedValue({ cost: 1 }) mocks.series.mockResolvedValue([]) mocks.breakdown.mockResolvedValue([]) + mocks.entities.mockResolvedValue(new Map()) mocks.logs.mockResolvedValue({ logs: [], pagination: { hasMore: false, nextCursorKeys: null } }) }) afterEach(() => vi.useRealTimers()) @@ -424,6 +426,17 @@ describe('organization usage API authorization and bounds', () => { expect(mocks.totals).not.toHaveBeenCalled() }) + it('keeps member avatars out of the public breakdown', async () => { + admin() + mocks.breakdown.mockResolvedValue([{ key: 'user-1', cost: 1, events: 1 }]) + mocks.entities.mockResolvedValue(new Map([['user-1', { name: 'Ada', image: 'a.png' }]])) + const response = await breakdown(request('usage/breakdown?dimension=member'), usageContext) + expect(response.status).toBe(200) + const { data } = await response.json() + expect(data.rows).toEqual([expect.objectContaining({ id: 'user-1', label: 'Ada' })]) + expect(data.rows[0]).not.toHaveProperty('image') + }) + it('reports an oversized breakdown instead of presenting a partial total', async () => { admin() mocks.breakdown.mockResolvedValue( diff --git a/apps/sim/ee/organization-usage/components/activity-table.tsx b/apps/sim/ee/organization-usage/components/activity-table.tsx index e7ae87dff37..e4acf0dcbb1 100644 --- a/apps/sim/ee/organization-usage/components/activity-table.tsx +++ b/apps/sim/ee/organization-usage/components/activity-table.tsx @@ -79,7 +79,7 @@ export function ActivityTable({ rows, dimension, onSelectWorkspace }: ActivityTa ) : (
- {isMember && } + {isMember && }
)} diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index aef6f01de4e..3f6b7ba8670 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ComponentType, ReactNode } from 'react' +import type { ComponentType } from 'react' import { cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn' import { ArrowRight, ChevronDown } from '@sim/emcn/icons' import { @@ -34,6 +34,7 @@ import type { OrganizationUsageBreakdownRow, UsageBreakdownDimension, } from '@/lib/api/contracts/organization-usage' +import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { type RowAction, RowActionsMenu, @@ -89,8 +90,8 @@ export const USAGE_PROVIDER_ICON_IDS = Object.keys(PROVIDER_ICONS) interface UsageConsumerRowProps { row: OrganizationUsageBreakdownRow - /** Replaces the provider mark, e.g. with a member's avatar. */ - leading?: ReactNode + /** Member rows lead with the member's avatar where other rows show a provider mark. */ + isMember: boolean /** BYOK rows carry no cost, so tokens are the only usage they can show. */ showTokensOnly: boolean onSelect?: (row: OrganizationUsageBreakdownRow) => void @@ -128,7 +129,7 @@ export const USAGE_ROW_CLASSES = 'flex w-full items-center gap-2.5 rounded-lg p- */ function UsageConsumerRow({ row, - leading, + isMember, showTokensOnly, onSelect, actions, @@ -151,8 +152,11 @@ function UsageConsumerRow({ onSelect && 'transition-colors hover-hover:bg-[var(--surface-active)]' )} > - {leading ?? - (ProviderIcon && )} + {isMember ? ( + + ) : ( + ProviderIcon && + )} {row.label}
void /** Set on Members, where a row can open the shared manage-credits modal. */ rowActions?: (row: OrganizationUsageBreakdownRow) => RowAction[] - /** Leading visual per row, in place of the provider mark. */ - renderLeading?: (row: OrganizationUsageBreakdownRow) => ReactNode /** * Opens the truncated tail. Omitted when the list is already showing everything the * API will return, which is the one case where the `Other` row has nothing to open. @@ -206,7 +208,6 @@ export function UsageConsumers({ isPlaceholderData, onSelectRow, rowActions, - renderLeading, onExpandOther, }: UsageConsumersProps) { if (isError) { @@ -245,7 +246,7 @@ export function UsageConsumers({ - {image && } - - - ) -} diff --git a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx index 82a241e2560..457fc64fe21 100644 --- a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx +++ b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx @@ -29,7 +29,6 @@ import { ActivityPanel } from '@/ee/organization-usage/components/activity-panel import { OrganizationActivityOverview } from '@/ee/organization-usage/components/activity-summary' import { UsageConsumers } from '@/ee/organization-usage/components/usage-consumers' import { UsageCredits } from '@/ee/organization-usage/components/usage-credits' -import { UsageMemberAvatar } from '@/ee/organization-usage/components/usage-member-avatar' import { UsageTopCards } from '@/ee/organization-usage/components/usage-top-cards' import { COLLAPSED_ROW_COUNT, @@ -378,11 +377,6 @@ export function UsageMonitoring({ void setState({ workspace: row.id, expanded: null }, { history: 'push' }), } : {})} - {...(tab === 'member' - ? { - renderLeading: (row) => , - } - : {})} {...(canManageCredits ? { rowActions: (row) => [ diff --git a/apps/sim/ee/organization-usage/components/usage-top-cards.tsx b/apps/sim/ee/organization-usage/components/usage-top-cards.tsx index 00cd51b1c28..4e365501e44 100644 --- a/apps/sim/ee/organization-usage/components/usage-top-cards.tsx +++ b/apps/sim/ee/organization-usage/components/usage-top-cards.tsx @@ -8,7 +8,6 @@ import { } from '@/lib/api/contracts/organization-usage' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { UsageConsumers } from '@/ee/organization-usage/components/usage-consumers' -import { UsageMemberAvatar } from '@/ee/organization-usage/components/usage-member-avatar' import type { UsageTab } from '@/ee/organization-usage/constants' import { useOrganizationUsageBreakdown } from '@/hooks/queries/organization-usage' import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys' @@ -64,7 +63,6 @@ export function UsageTopCards({ onViewAll, }: UsageTopCardsProps) { const models = useOrganizationUsageBreakdown(organizationId, window, 'model') - const images = new Map(overview?.members.rows.map((row) => [row.id, row.image])) return (
@@ -94,9 +92,6 @@ export function UsageTopCards({ isLoading={isOverviewLoading} isError={isOverviewError} isPlaceholderData={isOverviewPlaceholderData} - renderLeading={(row) => ( - - )} />
diff --git a/apps/sim/lib/api/contracts/organization-activity.ts b/apps/sim/lib/api/contracts/organization-activity.ts index c1e669ce82a..ec1a050d43f 100644 --- a/apps/sim/lib/api/contracts/organization-activity.ts +++ b/apps/sim/lib/api/contracts/organization-activity.ts @@ -53,6 +53,8 @@ export const organizationActivityBreakdownSchema = z.object({ label: z.string(), workspaceId: workspaceIdSchema.nullable(), workspaceName: z.string().nullable(), + /** Member rows only, when the member has one. */ + image: z.string().optional(), }) ) .max(ACTIVITY_PAGE_SIZE), diff --git a/apps/sim/lib/api/contracts/organization-usage.ts b/apps/sim/lib/api/contracts/organization-usage.ts index 64c7a66f259..5b4026eb5f2 100644 --- a/apps/sim/lib/api/contracts/organization-usage.ts +++ b/apps/sim/lib/api/contracts/organization-usage.ts @@ -217,8 +217,6 @@ export const organizationUsageBreakdownRowSchema = z.object({ /** Model dimensions only; BYOK rows carry no cost, so this is their only usage figure. */ tokens: z.number().int().optional().describe('Input and output tokens for model or BYOK groups.'), }) -export type OrganizationUsageBreakdownRow = z.output - export const organizationUsageBreakdownResponseSchema = z.object({ dimension: usageBreakdownDimensionSchema, rows: z @@ -244,7 +242,20 @@ export const organizationUsageBreakdownResponseSchema = z.object({ 'Whole credits represented by this breakdown; workflow includes only workflow-attributed usage.' ), }) -export type OrganizationUsageBreakdown = z.output + +/** + * The dashboard's breakdown row: member rows add an avatar, which the public v2 + * breakdown (the shared schema above) omits. + */ +const organizationUsageBreakdownViewRowSchema = organizationUsageBreakdownRowSchema.extend({ + image: z.string().optional(), +}) +export type OrganizationUsageBreakdownRow = z.output + +const organizationUsageBreakdownViewSchema = organizationUsageBreakdownResponseSchema.extend({ + rows: z.array(organizationUsageBreakdownViewRowSchema), +}) +export type OrganizationUsageBreakdown = z.output /** Rows on each overview card; the matching tab holds the full ranking. */ export const ORGANIZATION_USAGE_OVERVIEW_ROW_LIMIT = 5 @@ -270,10 +281,10 @@ export const organizationUsageOverviewResponseSchema = organizationUsageSummaryR ) .max(1000) .describe('Chronological buckets, including buckets with no usage.'), - /** The Members tab's ranking, cut to the card, with each member's avatar. */ - members: organizationUsageBreakdownResponseSchema.extend({ + /** The Members tab's ranking, cut to the card. */ + members: organizationUsageBreakdownViewSchema.extend({ rows: z - .array(organizationUsageBreakdownRowSchema.extend({ image: z.string().nullable() })) + .array(organizationUsageBreakdownViewRowSchema) .max(ORGANIZATION_USAGE_OVERVIEW_ROW_LIMIT), }), }) @@ -310,7 +321,7 @@ export const getOrganizationUsageBreakdownContract = defineRouteContract({ path: '/api/organizations/[id]/usage/breakdown', params: z.object({ id: organizationIdSchema }), query: organizationUsageBreakdownQuerySchema, - response: { mode: 'json', schema: organizationUsageBreakdownResponseSchema }, + response: { mode: 'json', schema: organizationUsageBreakdownViewSchema }, }) export const listOrganizationUsageEventsContract = defineRouteContract({ diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts index af19e2b54e0..e8ea3488733 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts @@ -14,7 +14,11 @@ import { type UsageGroupRow, type UsageWindowPreset, } from '@/lib/billing/core/usage-analytics' -import { readUsageEntityNames, readUsageGroups } from '@/lib/billing/core/usage-analytics-queries' +import { + readUsageEntities, + readUsageGroups, + type UsageEntity, +} from '@/lib/billing/core/usage-analytics-queries' import type { BillingEntity } from '@/lib/billing/core/usage-log' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' import { @@ -47,6 +51,8 @@ export interface OrganizationUsageBreakdownRow { share: number providerId?: string tokens?: number + /** Member rows only, when the member has one. */ + image?: string } export interface OrganizationUsageBreakdownResult { @@ -143,9 +149,9 @@ export async function buildUsageBreakdown({ .slice(0, limit * 2) .map((row) => row.key) .filter((key): key is string => Boolean(key)) - const names = NAMED_DIMENSIONS.has(dimension) - ? await readUsageEntityNames(dimension, rankedIds) - : new Map() + const entities = NAMED_DIMENSIONS.has(dimension) + ? await readUsageEntities(dimension, rankedIds) + : new Map() const labelFor = (key: string | null): string => { /** @@ -163,7 +169,7 @@ export async function buildUsageBreakdown({ if (dimension === 'model') return key // A deleted workspace or workflow nulls its id on the ledger row, so a key that // resolves to no name is a live entity we could not read — not a deleted one. - return names.get(key) ?? key + return entities.get(key)?.name ?? key } // BYOK is denominated in tokens and every row costs zero, so ranking it by cost @@ -201,6 +207,7 @@ export async function buildUsageBreakdown({ dimension: dimension, rows: fold.rows.map((row, index) => { const tokens = tokensByKey.get(row.id) ?? 0 + const image = entities.get(row.id)?.image return { id: row.id, label: row.label, @@ -210,6 +217,7 @@ export async function buildUsageBreakdown({ ...(isModelDimension && tokens > 0 ? { tokens } : {}), ...(dimension === 'byok' ? { providerId: row.id } : {}), ...(dimension === 'model' && row.id ? { providerId: getProviderFromModel(row.id) } : {}), + ...(image ? { image } : {}), } }), other: { diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts index 118f4b25753..c932f14c1dd 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts @@ -12,8 +12,7 @@ const mocks = vi.hoisted(() => ({ getOrgUsageLimit: vi.fn(), readUsageDays: vi.fn(), readUsageGroups: vi.fn(), - readUsageEntityNames: vi.fn(), - readUsageMemberProfiles: vi.fn(), + readUsageEntities: vi.fn(), })) vi.mock('@/lib/core/application/organization-authorization', () => ({ @@ -29,8 +28,7 @@ vi.mock('@/lib/billing/core/usage', () => ({ getOrgUsageLimit: mocks.getOrgUsage vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({ readUsageDays: mocks.readUsageDays, readUsageGroups: mocks.readUsageGroups, - readUsageEntityNames: mocks.readUsageEntityNames, - readUsageMemberProfiles: mocks.readUsageMemberProfiles, + readUsageEntities: mocks.readUsageEntities, })) vi.mock('@/providers/models', () => ({ getProviderFromModel: () => 'openai', @@ -82,13 +80,13 @@ describe('getOrganizationUsageOverview', () => { events: 1, })) ) - mocks.readUsageEntityNames.mockImplementation( - async (_dimension: string, ids: string[]) => new Map(ids.map((id) => [id, `Member ${id}`])) - ) - mocks.readUsageMemberProfiles.mockImplementation( - async (ids: string[]) => + mocks.readUsageEntities.mockImplementation( + async (_dimension: string, ids: string[]) => new Map( - ids.map((id) => [id, { name: `Member ${id}`, image: id === 'u0' ? 'a.png' : null }]) + ids.map((id) => [ + id, + { name: `Member ${id}`, ...(id === 'u0' ? { image: 'a.png' } : {}) }, + ]) ) ) }) @@ -118,6 +116,8 @@ describe('getOrganizationUsageOverview', () => { const { members } = await run({}) expect(members.rows.map((row) => row.id)).toEqual(['u0', 'u1', 'u2', 'u3', 'u4']) expect(members.rows[0]?.image).toBe('a.png') + expect(members.rows[1]).not.toHaveProperty('image') + expect(mocks.readUsageEntities).toHaveBeenCalledTimes(1) expect(members.other.rowCount).toBe(2) expect(mocks.readUsageGroups).toHaveBeenCalledWith( expect.objectContaining({ dimension: 'member' }) diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.ts index 73ddc541960..1bf27d61765 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.ts @@ -18,7 +18,7 @@ import { usageBucketTimestamps, usageWindowBounds, } from '@/lib/billing/core/usage-analytics' -import { readUsageDays, readUsageMemberProfiles } from '@/lib/billing/core/usage-analytics-queries' +import { readUsageDays } from '@/lib/billing/core/usage-analytics-queries' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' import { type BillingUsageLogSource, @@ -47,9 +47,7 @@ export interface OrganizationUsageOverviewResult { credits: number sources: Partial> }> - members: OrganizationUsageBreakdownResult & { - rows: Array - } + members: OrganizationUsageBreakdownResult } /** @@ -95,15 +93,6 @@ export const getOrganizationUsageOverview = defineAuthorizedOrganizationUsageUse window, dimension: 'member', limit: ORGANIZATION_USAGE_OVERVIEW_ROW_LIMIT, - }).then(async (breakdown) => { - const profiles = await readUsageMemberProfiles(breakdown.rows.map((row) => row.id)) - return { - ...breakdown, - rows: breakdown.rows.map((row) => ({ - ...row, - image: profiles.get(row.id)?.image ?? null, - })), - } }), subscription ? getOrgUsageLimit( diff --git a/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts b/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts index 92d3a5cdea1..4d55881b6de 100644 --- a/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts +++ b/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({ readUsageTotals: mocks.readUsageTotals, readUsageTimeSeries: mocks.readUsageTimeSeries, readUsageBreakdown: vi.fn(), - readUsageEntityNames: vi.fn(), + readUsageEntities: vi.fn(), })) import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary' diff --git a/apps/sim/lib/billing/core/organization-activity-queries.ts b/apps/sim/lib/billing/core/organization-activity-queries.ts index e079c574d0a..aab2c93088d 100644 --- a/apps/sim/lib/billing/core/organization-activity-queries.ts +++ b/apps/sim/lib/billing/core/organization-activity-queries.ts @@ -230,6 +230,7 @@ export async function readActivityBreakdown( const hasWorkspace = dimension === 'workspace' || dimension === 'workflow' const workspaceId = hasWorkspace ? sql`a.workspace_id` : sql`NULL::text` const workspaceName = hasWorkspace ? sql`w.name` : sql`NULL::text` + const image = dimension === 'member' ? sql`u.image` : sql`NULL::text` const order = { runs: sql`("workflowRuns" + "chatRuns") DESC`, failures: sql`failed DESC`, @@ -241,6 +242,7 @@ export async function readActivityBreakdown( label: string workspaceId: string | null workspaceName: string | null + image: string | null } >(sql` WITH activity AS (${activityGroups( @@ -252,7 +254,7 @@ export async function readActivityBreakdown( FROM activity a GROUP BY 1, 2 ), named AS ( - SELECT a.*, ${label} AS label, ${workspaceName} AS "workspaceName" + SELECT a.*, ${label} AS label, ${workspaceName} AS "workspaceName", ${image} AS image FROM grouped a ${hasWorkspace ? sql`LEFT JOIN ${workspace} w ON w.id = a."workspaceId"` : sql``} ${dimension === 'workflow' ? sql`LEFT JOIN ${workflow} f ON f.id = a.id` : sql``} @@ -267,6 +269,7 @@ export async function readActivityBreakdown( label: row.label, workspaceId: row.workspaceId, workspaceName: row.workspaceName, + ...(row.image ? { image: row.image } : {}), ...activityMetrics(row), })), hasMore: rows.length > ACTIVITY_PAGE_SIZE, diff --git a/apps/sim/lib/billing/core/organization-activity.postgres.test.ts b/apps/sim/lib/billing/core/organization-activity.postgres.test.ts index f853c241c73..2e8e1206891 100644 --- a/apps/sim/lib/billing/core/organization-activity.postgres.test.ts +++ b/apps/sim/lib/billing/core/organization-activity.postgres.test.ts @@ -63,14 +63,14 @@ beforeAll(async () => { await connection.unsafe(` CREATE TABLE workspace (id text PRIMARY KEY, name text, organization_id text); CREATE TABLE workflow (id text PRIMARY KEY, name text); - CREATE TABLE "user" (id text PRIMARY KEY, name text); + CREATE TABLE "user" (id text PRIMARY KEY, name text, image text); CREATE TABLE workflow_execution_logs (id text PRIMARY KEY, workspace_id text, workflow_id text, trigger text, started_at timestamp, status text, total_duration_ms integer); CREATE TABLE copilot_chats (id text PRIMARY KEY, workspace_id text, organization_id text); CREATE TABLE copilot_runs (id text PRIMARY KEY, chat_id text, execution_id text, user_id text, started_at timestamp); INSERT INTO workspace VALUES ('w1', 'Support', 'org'), ('w2', 'Sales', 'org'), ('foreign', 'Private', 'other'); INSERT INTO workflow VALUES ('f1', 'Triage'), ('f2', 'Follow up'); - INSERT INTO "user" VALUES ('m1', 'Alex'), ('m2', 'Sam'); + INSERT INTO "user" VALUES ('m1', 'Alex', 'alex.png'), ('m2', 'Sam', NULL); INSERT INTO workflow_execution_logs VALUES ('l1', 'w1', 'f1', 'manual', '2026-03-08 08:00:00', 'completed', 1000), ('l2', 'w1', 'f1', 'api', '2026-03-09 06:59:59', 'failed', 3000), @@ -180,6 +180,8 @@ describe.skipIf(!databaseUrl)('organization activity SQL', () => { ['m1', 2, 0], ['m2', 1, 0], ]) + expect(members.rows[0]?.image).toBe('alex.png') + expect(members.rows[1]).not.toHaveProperty('image') const workflows = await readActivityBreakdown(scope, 'workflow', 'duration', 0) expect(workflows.rows[0]).toMatchObject({ id: 'f1', averageDurationMs: 2000, workflowRuns: 3 }) expect(workflows.rows.find((row) => row.id === 'deleted:w2')).toMatchObject({ diff --git a/apps/sim/lib/billing/core/usage-analytics-queries.ts b/apps/sim/lib/billing/core/usage-analytics-queries.ts index ad5675620e7..fd6462b7562 100644 --- a/apps/sim/lib/billing/core/usage-analytics-queries.ts +++ b/apps/sim/lib/billing/core/usage-analytics-queries.ts @@ -68,26 +68,6 @@ export async function readUsageTimeSeries( .from(buckets) } -export interface UsageMemberProfile { - name: string - image: string | null -} - -/** Names and avatars for the ranked members only, after the aggregate has picked them. */ -export async function readUsageMemberProfiles( - ids: string[], - executor: DbClient = dbReplica -): Promise> { - if (ids.length === 0) return new Map() - const rows = await executor - .select({ id: user.id, name: user.name, email: user.email, image: user.image }) - .from(user) - .where(inArray(user.id, ids)) - return new Map( - rows.map((row) => [row.id, { name: row.name?.trim() || row.email, image: row.image }]) - ) -} - export interface UsageTotals { cost: number } @@ -331,7 +311,7 @@ export async function readUsageGroups({ /** * Ranked totals for one dimension. * - * Aggregate-first: names are hydrated by {@link readUsageEntityNames} for the + * Aggregate-first: names and avatars are hydrated by {@link readUsageEntities} for the * surviving keys only. Joining inside the aggregate would break index-only for * `member` and force a nested loop across the whole window. */ @@ -391,22 +371,35 @@ export async function readUsageBreakdown( return maxRows === undefined ? query : query.limit(maxRows + 1) } +export interface UsageEntity { + name: string + image?: string +} + /** - * Display names for the top-N keys of an entity-backed dimension. + * Names, and member avatars, for the top-N keys of an entity-backed dimension. * * Members fall back to email because a user may have no name set, and an empty row * label is worse than an address. */ -export async function readUsageEntityNames( +export async function readUsageEntities( dimension: UsageBreakdownDimension, ids: string[], executor: DbClient = dbReplica -): Promise> { +): Promise> { if (ids.length === 0) return new Map() if (dimension === 'member') { - const profiles = await readUsageMemberProfiles(ids, executor) - return new Map([...profiles].map(([id, profile]) => [id, profile.name])) + const rows = await executor + .select({ id: user.id, name: user.name, email: user.email, image: user.image }) + .from(user) + .where(inArray(user.id, ids)) + return new Map( + rows.map((row) => [ + row.id, + { name: row.name?.trim() || row.email, ...(row.image ? { image: row.image } : {}) }, + ]) + ) } if (dimension === 'workspace') { @@ -414,7 +407,7 @@ export async function readUsageEntityNames( .select({ id: workspace.id, name: workspace.name }) .from(workspace) .where(inArray(workspace.id, ids)) - return new Map(rows.map((row) => [row.id, row.name])) + return new Map(rows.map((row) => [row.id, { name: row.name }])) } if (dimension === 'workflow') { @@ -422,7 +415,7 @@ export async function readUsageEntityNames( .select({ id: workflow.id, name: workflow.name }) .from(workflow) .where(inArray(workflow.id, ids)) - return new Map(rows.map((row) => [row.id, row.name])) + return new Map(rows.map((row) => [row.id, { name: row.name }])) } return new Map() diff --git a/apps/sim/lib/mothership/tools/server/settings-collections.test.ts b/apps/sim/lib/mothership/tools/server/settings-collections.test.ts index 9408cb8e8c8..38c4b89f2c2 100644 --- a/apps/sim/lib/mothership/tools/server/settings-collections.test.ts +++ b/apps/sim/lib/mothership/tools/server/settings-collections.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { projectSettingsRoster, + projectSettingsUsageBreakdown, settingsPage, settingsPageSchema, } from '@/lib/mothership/tools/server/settings-collections' @@ -58,4 +59,20 @@ describe('bounded Settings collection projections', () => { expect(JSON.stringify(result)).not.toContain('private-image') expect(JSON.stringify(result)).not.toContain('"workspaces":[') }) + + it('keeps member avatars out of the usage breakdown the model reads', () => { + const result = projectSettingsUsageBreakdown({ + dimension: 'member', + rows: [ + { id: 'u1', label: 'Ada', image: 'private-image' }, + { id: 'u2', label: 'Sam' }, + ], + totalCredits: 3, + }) + expect(result.rows).toEqual([ + { id: 'u1', label: 'Ada' }, + { id: 'u2', label: 'Sam' }, + ]) + expect(result).toMatchObject({ dimension: 'member', totalCredits: 3 }) + }) }) diff --git a/apps/sim/lib/mothership/tools/server/settings-collections.ts b/apps/sim/lib/mothership/tools/server/settings-collections.ts index 5aa2a9d84b8..f57a4812e9a 100644 --- a/apps/sim/lib/mothership/tools/server/settings-collections.ts +++ b/apps/sim/lib/mothership/tools/server/settings-collections.ts @@ -28,6 +28,14 @@ export function settingsPage(rows: readonly T[], page: SettingsPage, key: (ro } } +/** Avatars are for the dashboard; the model gets names, as it does from the roster. */ +export function projectSettingsUsageBreakdown({ + rows, + ...breakdown +}: T) { + return { ...breakdown, rows: rows.map(({ image: _image, ...row }) => row) } +} + type Roster = Awaited> export function projectSettingsRoster(roster: Roster, page = initialSettingsPage) { return { diff --git a/apps/sim/lib/mothership/tools/server/settings-operations.ts b/apps/sim/lib/mothership/tools/server/settings-operations.ts index 614c8e249fa..d863a971f99 100644 --- a/apps/sim/lib/mothership/tools/server/settings-operations.ts +++ b/apps/sim/lib/mothership/tools/server/settings-operations.ts @@ -54,6 +54,7 @@ import { import { archivedChatSettingsActions } from '@/lib/mothership/tools/server/settings-chats' import { projectSettingsGroupMember, + projectSettingsUsageBreakdown, projectSettingsWorkspace, readSettingsGroups, readSettingsRoster, @@ -455,11 +456,13 @@ export const settingsOperations: Record - getOrganizationUsageBreakdown.execute({ - principal: context.principal, - input: { ...dates(input), organizationId: settingsOrganizationId(context) }, - }) + async (context, input) => + projectSettingsUsageBreakdown( + await getOrganizationUsageBreakdown.execute({ + principal: context.principal, + input: { ...dates(input), organizationId: settingsOrganizationId(context) }, + }) + ) ), events: settingsOperation( 'read', From e954303de128958e396ae93183f43dfbe03958ff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 18:22:36 -0700 Subject: [PATCH 2/3] fix(insights): read names and avatars for the whole tie at the breakdown cutoff --- .../get-organization-usage-breakdown.ts | 17 +++++++++-------- .../get-organization-usage-overview.test.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts index e8ea3488733..9464310ed93 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts @@ -138,15 +138,16 @@ export async function buildUsageBreakdown({ * Names are hydrated for the surviving keys only — joining inside the aggregate * would break the index-only scan the member dimension depends on. * - * Sorted before slicing: the breakdown query only groups, so Postgres returns its - * aggregate in arbitrary order. Slicing that directly hydrated an arbitrary subset - * while the fold below ranks by cost, so a top row whose name was never fetched - * fell through to `?? key` and rendered a raw id. The margin over `limit` covers - * the fold's label tiebreak pulling in a row just past the cut. + * Sorted before cutting: the breakdown query only groups, so Postgres returns its + * aggregate in arbitrary order. The fold below breaks cost ties by label, so every + * row tied with the last visible one can still win a place — and a label is only + * right once its name is read. So the read covers the top `limit` and the whole tie + * at the cutoff, never a guessed margin that a larger tie outruns. */ - const rankedIds = [...rows] - .sort((left, right) => right.cost - left.cost) - .slice(0, limit * 2) + const byCost = [...rows].sort((left, right) => right.cost - left.cost) + const cutoffCost = byCost[limit - 1]?.cost + const rankedIds = byCost + .filter((row, index) => index < limit || row.cost === cutoffCost) .map((row) => row.key) .filter((key): key is string => Boolean(key)) const entities = NAMED_DIMENSIONS.has(dimension) diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts index c932f14c1dd..8e231a2068d 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts @@ -95,6 +95,20 @@ describe('getOrganizationUsageOverview', () => { setEnvFlags({ isBillingEnabled: false, isHosted: false }) }) + it('reads every member tied at the card cutoff, so each visible row is named', async () => { + mocks.readUsageGroups.mockResolvedValue( + Array.from({ length: 12 }, (_, index) => ({ key: `u${index}`, cost: 0.01, events: 1 })) + ) + mocks.readUsageEntities.mockImplementation( + async (_dimension: string, ids: string[]) => + new Map(ids.map((id) => [id, { name: id === 'u11' ? 'Ada' : `Member ${id}` }])) + ) + const { members } = await run({}) + expect(mocks.readUsageEntities.mock.calls[0]?.[1]).toHaveLength(12) + expect(members.rows[0]?.label).toBe('Ada') + expect(members.rows.every((row) => !row.label.startsWith('u'))).toBe(true) + }) + it('reconciles the stack and the headline to one figure', async () => { const result = await run({}) const point = result.series.find((entry) => entry.timestamp === today) From d3e8e281ed051c988c1787ce496450877026f928 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 18:32:37 -0700 Subject: [PATCH 3/3] fix(insights): cut breakdowns by key so the entity read stays bounded --- .../get-organization-usage-breakdown.ts | 25 ++++------ .../get-organization-usage-overview.test.ts | 22 ++++++--- .../lib/billing/core/usage-analytics.test.ts | 12 +++++ apps/sim/lib/billing/core/usage-analytics.ts | 47 ++++++++++++++----- 4 files changed, 73 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts index 9464310ed93..be84912da45 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts @@ -7,6 +7,7 @@ import { organizationUsageOperations } from '@/lib/billing/application/organizat import { foldUsageBreakdown, mergeRowsByKey, + rankUsageRows, resolveUsageAnalyticsWindow, USAGE_NULL_KEY_LABELS, type UsageAnalyticsWindow, @@ -138,16 +139,14 @@ export async function buildUsageBreakdown({ * Names are hydrated for the surviving keys only — joining inside the aggregate * would break the index-only scan the member dimension depends on. * - * Sorted before cutting: the breakdown query only groups, so Postgres returns its - * aggregate in arbitrary order. The fold below breaks cost ties by label, so every - * row tied with the last visible one can still win a place — and a label is only - * right once its name is read. So the read covers the top `limit` and the whole tie - * at the cutoff, never a guessed margin that a larger tie outruns. + * Cut with the fold's own ranking, so the rows read here are exactly the rows it + * keeps: the breakdown query only groups, and Postgres returns its aggregate in + * arbitrary order. The ranking breaks ties by key, never by a name not yet read, so + * the read is bounded by `limit` however large a tie at the cutoff. */ - const byCost = [...rows].sort((left, right) => right.cost - left.cost) - const cutoffCost = byCost[limit - 1]?.cost - const rankedIds = byCost - .filter((row, index) => index < limit || row.cost === cutoffCost) + const rankBy = dimension === 'byok' ? 'tokens' : 'cost' + const rankedIds = rankUsageRows(rows, rankBy) + .slice(0, limit) .map((row) => row.key) .filter((key): key is string => Boolean(key)) const entities = NAMED_DIMENSIONS.has(dimension) @@ -175,13 +174,7 @@ export async function buildUsageBreakdown({ // BYOK is denominated in tokens and every row costs zero, so ranking it by cost // would order the list alphabetically and call the result "top providers". - const fold = foldUsageBreakdown( - rows, - totalCost, - labelFor, - limit, - dimension === 'byok' ? 'tokens' : 'cost' - ) + const fold = foldUsageBreakdown(rows, totalCost, labelFor, limit, rankBy) const tokensByKey = new Map( rows.map((row) => [row.key ?? '', (row.inputTokens ?? 0) + (row.outputTokens ?? 0)]) ) diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts index 8e231a2068d..60b07e8ba4a 100644 --- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts +++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts @@ -95,18 +95,28 @@ describe('getOrganizationUsageOverview', () => { setEnvFlags({ isBillingEnabled: false, isHosted: false }) }) - it('reads every member tied at the card cutoff, so each visible row is named', async () => { + it('reads names only for the kept rows, however large a tie at the cutoff', async () => { mocks.readUsageGroups.mockResolvedValue( - Array.from({ length: 12 }, (_, index) => ({ key: `u${index}`, cost: 0.01, events: 1 })) + Array.from({ length: 12 }, (_, index) => ({ + key: `u${String(index).padStart(2, '0')}`, + cost: 0.01, + events: 1, + })) ) mocks.readUsageEntities.mockImplementation( async (_dimension: string, ids: string[]) => - new Map(ids.map((id) => [id, { name: id === 'u11' ? 'Ada' : `Member ${id}` }])) + new Map(ids.map((id) => [id, { name: `Member ${id}` }])) ) const { members } = await run({}) - expect(mocks.readUsageEntities.mock.calls[0]?.[1]).toHaveLength(12) - expect(members.rows[0]?.label).toBe('Ada') - expect(members.rows.every((row) => !row.label.startsWith('u'))).toBe(true) + expect(mocks.readUsageEntities.mock.calls[0]?.[1]).toEqual(['u00', 'u01', 'u02', 'u03', 'u04']) + expect(members.rows.map((row) => row.label)).toEqual([ + 'Member u00', + 'Member u01', + 'Member u02', + 'Member u03', + 'Member u04', + ]) + expect(members.other.rowCount).toBe(7) }) it('reconciles the stack and the headline to one figure', async () => { diff --git a/apps/sim/lib/billing/core/usage-analytics.test.ts b/apps/sim/lib/billing/core/usage-analytics.test.ts index e1ae0c7c310..9d84cd9c381 100644 --- a/apps/sim/lib/billing/core/usage-analytics.test.ts +++ b/apps/sim/lib/billing/core/usage-analytics.test.ts @@ -509,6 +509,18 @@ describe('foldUsageBreakdown', () => { expect(foldUsageBreakdown(rows, 10, labelFor, 3).rows.map((r) => r.id)).toEqual(['a', 'b', 'c']) }) + it('decides a tie at the cutoff by key, then orders the kept rows by label', () => { + const tied = [ + { key: 'k3', cost: '1', events: 1 }, + { key: 'k1', cost: '1', events: 1 }, + { key: 'k2', cost: '1', events: 1 }, + ] + const names: Record = { k1: 'Zed', k2: 'Ada', k3: 'Bo' } + const fold = foldUsageBreakdown(tied, 3, (key) => names[key ?? ''] ?? '', 2) + expect(fold.rows.map((row) => row.label)).toEqual(['Ada', 'Zed']) + expect(fold.other.rowCount).toBe(1) + }) + it('computes share against the window total, not the visible subset', () => { // Sharing against the visible rows would make a truncated list read as 100%. const fold = foldUsageBreakdown(rows, 10, labelFor, 1) diff --git a/apps/sim/lib/billing/core/usage-analytics.ts b/apps/sim/lib/billing/core/usage-analytics.ts index faef0175b5e..92e6b50f9c7 100644 --- a/apps/sim/lib/billing/core/usage-analytics.ts +++ b/apps/sim/lib/billing/core/usage-analytics.ts @@ -1,4 +1,5 @@ import { usageLog } from '@sim/db/schema' +import { compareStrings } from '@sim/utils/string' import { eq, gte, lt, type SQL } from 'drizzle-orm' import { MAX_CUSTOM_RANGE_DAYS } from '@/lib/api/contracts/organization-usage' import { @@ -616,6 +617,29 @@ interface RankedRow { outputTokens?: number } +export type UsageRankMetric = 'cost' | 'tokens' + +const usageRankValue = (row: RankedRow, rankBy: UsageRankMetric) => + rankBy === 'tokens' ? (row.inputTokens ?? 0) + (row.outputTokens ?? 0) : toNumber(row.cost) + +/** + * The order that decides which rows a ranked list keeps: by the metric, then by key. + * + * The tiebreak is the key, not the label, because a label is a name read *after* the + * cut — only the kept rows are ever named. Deciding the cut by name would mean reading + * every row tied at it, which a large tie turns into an unbounded lookup. + */ +export function rankUsageRows( + rows: readonly T[], + rankBy: UsageRankMetric +): T[] { + return [...rows].sort( + (left, right) => + usageRankValue(right, rankBy) - usageRankValue(left, rankBy) || + compareStrings(left.key ?? '', right.key ?? '') + ) +} + /** * Ranks a dimension and closes it with an explicit remainder. * @@ -633,23 +657,24 @@ export function foldUsageBreakdown( totalCost: number, labelFor: (key: string | null) => string, limit: number, - rankBy: 'cost' | 'tokens' = 'cost' + rankBy: UsageRankMetric = 'cost' ): UsageBreakdownFold { - const ranked = rows - .map((row) => ({ - id: row.key ?? '', - label: labelFor(row.key), - cost: toNumber(row.cost), - events: Math.round(toNumber(row.events)), - tokens: (row.inputTokens ?? 0) + (row.outputTokens ?? 0), - })) + const ranked = rankUsageRows(rows, rankBy).map((row) => ({ + id: row.key ?? '', + label: labelFor(row.key), + cost: toNumber(row.cost), + events: Math.round(toNumber(row.events)), + tokens: (row.inputTokens ?? 0) + (row.outputTokens ?? 0), + })) + + /** Ties among the kept rows read alphabetically; the cut itself was decided above. */ + const visible = ranked + .slice(0, limit) .sort( (left, right) => (rankBy === 'tokens' ? right.tokens - left.tokens : right.cost - left.cost) || left.label.localeCompare(right.label) ) - - const visible = ranked.slice(0, limit) const hidden = ranked.slice(limit) /** * Share is measured in whatever the list is ranked by, because it is what draws the