From e1d336b8f8c5e817cbea68ae18c77d6601469b82 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 15:14:38 -0600 Subject: [PATCH 1/9] feat(api): add public organization members endpoint --- .../organizations/[id]/members/route.test.ts | 90 +++++++++++++++++++ .../v1/organizations/[id]/members/route.ts | 12 +++ .../lib/organizations/organization-types.ts | 47 ++++++++++ 3 files changed, 149 insertions(+) create mode 100644 apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts create mode 100644 apps/web/src/app/api/v1/organizations/[id]/members/route.ts diff --git a/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts b/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts new file mode 100644 index 0000000000..f198e28d0e --- /dev/null +++ b/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts @@ -0,0 +1,90 @@ +import { beforeAll, describe, expect, it, jest } from '@jest/globals'; +import { NextRequest } from 'next/server'; +import type { handleTRPCRequest } from '@/lib/trpc-route-handler'; + +jest.mock('@/lib/trpc-route-handler', () => ({ + handleTRPCRequest: jest.fn(), +})); + +const { handleTRPCRequest: mockedHandleTRPCRequest } = jest.requireMock( + '@/lib/trpc-route-handler' +) as { + handleTRPCRequest: jest.MockedFunction; +}; + +let GET: typeof import('./route').GET; + +beforeAll(async () => { + ({ GET } = await import('./route')); +}); + +describe('GET /api/v1/organizations/[id]/members', () => { + it('returns public organization members without invited-member invite fields', async () => { + const invitedMember = { + email: 'invited@example.com', + role: 'member' as const, + inviteDate: '2026-06-22T00:00:00.000Z', + inviteToken: 'secret-token', + inviteId: 'invite-id', + status: 'invited' as const, + inviteUrl: 'https://example.com/users/accept-invite/secret-token', + dailyUsageLimitUsd: null, + currentDailyUsageUsd: null, + }; + const withMembers = jest.fn(async (_input: { organizationId: string }) => ({ + id: 'org-id', + name: 'Test Org', + members: [ + { + id: 'user-id', + name: 'Active User', + email: 'active@example.com', + role: 'owner' as const, + status: 'active' as const, + inviteDate: null, + dailyUsageLimitUsd: null, + currentDailyUsageUsd: null, + }, + invitedMember, + ], + })); + const caller = { + organizations: { + withMembers, + }, + }; + + mockedHandleTRPCRequest.mockImplementationOnce(async (_request, handler) => { + const result = await handler(caller as never); + return Response.json(result) as never; + }); + + const response = await GET(new NextRequest('http://localhost:3000'), { + params: Promise.resolve({ id: 'org-id' }), + }); + + expect(withMembers).toHaveBeenCalledWith({ organizationId: 'org-id' }); + expect(invitedMember).toHaveProperty('inviteToken', 'secret-token'); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual([ + { + id: 'user-id', + name: 'Active User', + email: 'active@example.com', + role: 'owner', + status: 'active', + inviteDate: null, + dailyUsageLimitUsd: null, + currentDailyUsageUsd: null, + }, + { + email: 'invited@example.com', + role: 'member', + inviteDate: '2026-06-22T00:00:00.000Z', + status: 'invited', + dailyUsageLimitUsd: null, + currentDailyUsageUsd: null, + }, + ]); + }); +}); diff --git a/apps/web/src/app/api/v1/organizations/[id]/members/route.ts b/apps/web/src/app/api/v1/organizations/[id]/members/route.ts new file mode 100644 index 0000000000..6dbf1745bf --- /dev/null +++ b/apps/web/src/app/api/v1/organizations/[id]/members/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from 'next/server'; +import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types'; +import { handleTRPCRequest } from '@/lib/trpc-route-handler'; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const organizationId = (await params).id; + + return handleTRPCRequest(request, async caller => { + const org = await caller.organizations.withMembers({ organizationId }); + return PublicOrganizationMembersSchema.parse(org.members); + }); +} diff --git a/apps/web/src/lib/organizations/organization-types.ts b/apps/web/src/lib/organizations/organization-types.ts index c1dde9b444..9f1e77b083 100644 --- a/apps/web/src/lib/organizations/organization-types.ts +++ b/apps/web/src/lib/organizations/organization-types.ts @@ -21,6 +21,8 @@ import type { OrganizationRole, OrganizationPlan } from './organization-base-typ import { OrganizationPlanSchema, OrganizationSettingsSchema } from './organization-base-types'; import { OpenCodeSettingsSchema } from '@kilocode/db/schema-types'; +export const OrganizationRoleSchema = z.enum(['owner', 'member', 'billing_manager']); + // API-facing billing cycle values: 'monthly' | 'annual' // The DB stores 'yearly' instead of 'annual'; Stripe uses 'year'/'month'. export const BillingCycleSchema = z.enum(['monthly', 'annual']); @@ -90,6 +92,18 @@ export type UserOrganizationWithSeats = { }; }; +export const InvitedOrganizationMemberSchema = z.object({ + email: z.string(), + role: OrganizationRoleSchema, + inviteDate: z.string().nullable(), + inviteToken: z.string(), + inviteId: z.string(), + status: z.literal('invited'), + inviteUrl: z.string(), + dailyUsageLimitUsd: z.number().nullable(), + currentDailyUsageUsd: z.number().nullable(), +}); + type InvitedMember = { email: string; role: OrganizationRole; @@ -102,6 +116,17 @@ type InvitedMember = { currentDailyUsageUsd: number | null; }; +export const ActiveOrganizationMemberSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + role: OrganizationRoleSchema, + status: z.literal('active'), + inviteDate: z.string().nullable(), + dailyUsageLimitUsd: z.number().nullable(), + currentDailyUsageUsd: z.number().nullable(), +}); + type ActiveMember = { id: string; name: string; @@ -115,6 +140,28 @@ type ActiveMember = { export type OrganizationMember = InvitedMember | ActiveMember; +export const OrganizationMemberSchema = z.discriminatedUnion('status', [ + ActiveOrganizationMemberSchema, + InvitedOrganizationMemberSchema, +]); + +export const PublicInvitedOrganizationMemberSchema = InvitedOrganizationMemberSchema.omit({ + inviteToken: true, + inviteId: true, + inviteUrl: true, +}); + +export const PublicOrganizationMemberSchema = z.discriminatedUnion('status', [ + ActiveOrganizationMemberSchema, + PublicInvitedOrganizationMemberSchema, +]); + +export const PublicOrganizationMembersSchema = z.array(PublicOrganizationMemberSchema); + +export const OrganizationWithMembersSchema = OrganizationSchema.extend({ + members: z.array(OrganizationMemberSchema), +}); + export type OrganizationSsoPolicyView = { required: boolean; source: 'self' | 'direct_parent' | null; From d4343ecc8ce25d68204ae65fcfa1351e6b5508f2 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 09:28:46 -0600 Subject: [PATCH 2/9] feat(usage): support email user filters --- .../routers/usage-analytics-router.test.ts | 71 ++++ .../web/src/routers/usage-analytics-router.ts | 363 ++++++++++++++++-- .../src/routers/usage-analytics-schemas.ts | 3 + 3 files changed, 400 insertions(+), 37 deletions(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index efe2113446..e1251f7094 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -3,8 +3,11 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); import { CostSourceSchema, UsageAnalyticsFiltersSchema, + applySelfEmailExclusion, + buildScopedUserEmailMaps, costColumnFor, costSumExprSql, + shouldLoadFullOrgWideUserEmailMap, } from './usage-analytics-router'; const baseFilters = { @@ -33,4 +36,72 @@ describe('usage analytics cost source', () => { CostSourceSchema.safeParse('total_cost_microdollars); DROP TABLE usage; --').success ).toBe(false); }); + + it('accepts email-based user filtering and email display mode', () => { + const filters = UsageAnalyticsFiltersSchema.parse({ + ...baseFilters, + userEmails: ['person@example.com'], + excludedUserEmails: ['excluded@example.com'], + userDisplay: 'email', + }); + + expect(filters.userEmails).toEqual(['person@example.com']); + expect(filters.excludedUserEmails).toEqual(['excluded@example.com']); + expect(filters.userDisplay).toBe('email'); + }); + + it('builds scoped email maps with canonical and legacy OAuth user ids', () => { + const maps = buildScopedUserEmailMaps( + [ + { id: 'user_1', email: 'person@example.com' }, + { id: 'user_2', email: 'other@example.com' }, + { id: 'user_without_email', email: null }, + ], + [ + { userId: 'user_1', provider: 'github', providerAccountId: '123' }, + { userId: 'user_1', provider: 'google', providerAccountId: 'abc' }, + { userId: 'missing_user', provider: 'github', providerAccountId: 'ignored' }, + ] + ); + + expect(maps.idsByEmail.get('person@example.com')).toEqual([ + 'user_1', + 'oauth/github:123', + 'oauth/google:abc', + ]); + expect(maps.idsByEmail.get('other@example.com')).toEqual(['user_2']); + expect(maps.emailsById.get('user_1')).toBe('person@example.com'); + expect(maps.emailsById.get('oauth/github:123')).toBe('person@example.com'); + expect(maps.emailsById.has('oauth/github:ignored')).toBe(false); + expect(maps.emailsById.has('user_without_email')).toBe(false); + }); + + it('translates self-scope excluded user emails to excluded user ids', () => { + const filters = UsageAnalyticsFiltersSchema.parse({ + ...baseFilters, + excludedUserEmails: ['person@example.com'], + }); + + expect( + applySelfEmailExclusion(filters, 'user_1', 'person@example.com').excludedUserIds + ).toEqual(['user_1']); + expect(applySelfEmailExclusion(filters, 'user_1', 'other@example.com').excludedUserIds).toBe( + undefined + ); + }); + + it('loads full org email maps only for email display requests', () => { + const filters = UsageAnalyticsFiltersSchema.parse({ + ...baseFilters, + organizationId: '00000000-0000-4000-8000-000000000001', + viewAs: 'org-wide', + userEmails: ['person@example.com'], + }); + + expect(shouldLoadFullOrgWideUserEmailMap(filters, false)).toBe(false); + expect(shouldLoadFullOrgWideUserEmailMap(filters, true)).toBe(false); + expect(shouldLoadFullOrgWideUserEmailMap({ ...filters, userDisplay: 'email' }, true)).toBe( + true + ); + }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 35858b2b13..5a606f4b34 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -197,6 +197,7 @@ class WhereBuilder { async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilters): Promise { const userId = ctx.user.id; + const userEmail = ctx.user.google_user_email; if (filters.organizationId) { const requiredRoles = filters.viewAs === 'org-wide' ? (['owner', 'billing_manager'] as const) : undefined; @@ -214,6 +215,16 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter message: 'Self-scope analytics can only filter to own user.', }); } + const allUserEmailFilterValues = [ + ...(filters.userEmails ?? []), + ...(filters.excludedUserEmails ?? []), + ]; + if (allUserEmailFilterValues.some(v => v !== userEmail)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Self-scope analytics can only filter to own user.', + }); + } } return; } @@ -225,6 +236,251 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter message: 'Personal analytics can only filter to own user.', }); } + const allUserEmailFilterValues = [ + ...(filters.userEmails ?? []), + ...(filters.excludedUserEmails ?? []), + ]; + if (allUserEmailFilterValues.some(v => v !== userEmail)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Personal analytics can only filter to own user.', + }); + } +} + +const NO_MATCHING_USER_EMAIL_ID = '__no_matching_kilo_user_email__'; + +type ScopedUserEmailMaps = { + idsByEmail: Map; + emailsById: Map; +}; + +type ScopedUserEmailMapUser = { + id: string; + email: string | null; +}; + +type ScopedUserEmailMapAuthProvider = { + userId: string; + provider: AuthProviderId; + providerAccountId: string; +}; + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function addScopedUserId(maps: ScopedUserEmailMaps, email: string, userId: string): void { + maps.emailsById.set(userId, email); + maps.idsByEmail.set(email, [...(maps.idsByEmail.get(email) ?? []), userId]); +} + +export function buildScopedUserEmailMaps( + users: ScopedUserEmailMapUser[], + authProviders: ScopedUserEmailMapAuthProvider[] +): ScopedUserEmailMaps { + const maps: ScopedUserEmailMaps = { idsByEmail: new Map(), emailsById: new Map() }; + + for (const user of users) { + if (!user.email) continue; + addScopedUserId(maps, user.email, user.id); + } + + for (const authProvider of authProviders) { + const email = maps.emailsById.get(authProvider.userId); + if (!email) continue; + addScopedUserId( + maps, + email, + `oauth/${authProvider.provider}:${authProvider.providerAccountId}` + ); + } + + for (const [email, ids] of maps.idsByEmail) { + maps.idsByEmail.set(email, uniqueStrings(ids)); + } + + return maps; +} + +async function loadOrgWideUserEmailMaps(organizationId: string): Promise { + const users = await readDb + .select({ id: kilocode_users.id, email: kilocode_users.google_user_email }) + .from(kilocode_users) + .innerJoin( + organization_memberships, + and( + eq(organization_memberships.kilo_user_id, kilocode_users.id), + eq(organization_memberships.organization_id, organizationId) + ) + ); + + const userIds = users.map(user => user.id); + if (userIds.length === 0) return buildScopedUserEmailMaps(users, []); + + const authProviders = await readDb + .select({ + userId: user_auth_provider.kilo_user_id, + provider: user_auth_provider.provider, + providerAccountId: user_auth_provider.provider_account_id, + }) + .from(user_auth_provider) + .where(inArray(user_auth_provider.kilo_user_id, userIds)); + + return buildScopedUserEmailMaps(users, authProviders); +} + +async function loadOrgWideUserEmailFilterMaps( + organizationId: string, + emails: string[] +): Promise { + const uniqueEmails = uniqueStrings(emails); + if (uniqueEmails.length === 0) return buildScopedUserEmailMaps([], []); + + const users = await readDb + .select({ id: kilocode_users.id, email: kilocode_users.google_user_email }) + .from(kilocode_users) + .innerJoin( + organization_memberships, + and( + eq(organization_memberships.kilo_user_id, kilocode_users.id), + eq(organization_memberships.organization_id, organizationId) + ) + ) + .where(inArray(kilocode_users.google_user_email, uniqueEmails)); + + const userIds = users.map(user => user.id); + if (userIds.length === 0) return buildScopedUserEmailMaps(users, []); + + const authProviders = await readDb + .select({ + userId: user_auth_provider.kilo_user_id, + provider: user_auth_provider.provider, + providerAccountId: user_auth_provider.provider_account_id, + }) + .from(user_auth_provider) + .where(inArray(user_auth_provider.kilo_user_id, userIds)); + + return buildScopedUserEmailMaps(users, authProviders); +} + +function needsOrgWideUserEmailMaps( + filters: UsageAnalyticsFilters, + includeUserEmailDisplay: boolean +): filters is UsageAnalyticsFilters & { + organizationId: string; + viewAs: 'org-wide'; +} { + return Boolean( + filters.organizationId && + filters.viewAs === 'org-wide' && + ((includeUserEmailDisplay && filters.userDisplay === 'email') || + (filters.userEmails && filters.userEmails.length > 0) || + (filters.excludedUserEmails && filters.excludedUserEmails.length > 0)) + ); +} + +export function shouldLoadFullOrgWideUserEmailMap( + filters: UsageAnalyticsFilters, + includeUserEmailDisplay: boolean +): boolean { + return Boolean( + filters.organizationId && + filters.viewAs === 'org-wide' && + includeUserEmailDisplay && + filters.userDisplay === 'email' + ); +} + +function lookupScopedUserIdsByEmails(maps: ScopedUserEmailMaps, emails: string[] = []): string[] { + return uniqueStrings(emails.flatMap(email => maps.idsByEmail.get(email) ?? [])); +} + +function emailFilterValues(filters: UsageAnalyticsFilters): string[] { + return uniqueStrings([...(filters.userEmails ?? []), ...(filters.excludedUserEmails ?? [])]); +} + +export function applySelfEmailExclusion( + filters: T, + userId: string, + userEmail: string | null +): T { + if (!userEmail || !filters.excludedUserEmails?.includes(userEmail)) return filters; + return { + ...filters, + excludedUserIds: uniqueStrings([...(filters.excludedUserIds ?? []), userId]), + }; +} + +async function loadSelfUserEmailMaps(ctx: TRPCContext): Promise { + const authProviders = await readDb + .select({ + userId: user_auth_provider.kilo_user_id, + provider: user_auth_provider.provider, + providerAccountId: user_auth_provider.provider_account_id, + }) + .from(user_auth_provider) + .where(eq(user_auth_provider.kilo_user_id, ctx.user.id)); + + return buildScopedUserEmailMaps( + [{ id: ctx.user.id, email: ctx.user.google_user_email }], + authProviders + ); +} + +async function prepareUsageFilters( + ctx: TRPCContext, + filters: T, + includeUserEmailDisplay = false +): Promise<{ filters: T; scopedUserEmailMaps?: ScopedUserEmailMaps }> { + await ensureScopeAccess(ctx, filters); + const needsOrgWideMaps = needsOrgWideUserEmailMaps(filters, includeUserEmailDisplay); + const needsUserEmailDisplay = includeUserEmailDisplay && filters.userDisplay === 'email'; + const prepared = { ...filters }; + + if (needsUserEmailDisplay && !needsOrgWideMaps) { + return { + filters: applySelfEmailExclusion(prepared, ctx.user.id, ctx.user.google_user_email), + scopedUserEmailMaps: await loadSelfUserEmailMaps(ctx), + }; + } + + if (!needsOrgWideMaps) { + return { filters: applySelfEmailExclusion(prepared, ctx.user.id, ctx.user.google_user_email) }; + } + + const scopedUserEmailMaps = shouldLoadFullOrgWideUserEmailMap(filters, includeUserEmailDisplay) + ? await loadOrgWideUserEmailMaps(filters.organizationId) + : await loadOrgWideUserEmailFilterMaps(filters.organizationId, emailFilterValues(filters)); + const userIdsFromEmails = lookupScopedUserIdsByEmails(scopedUserEmailMaps, filters.userEmails); + const excludedUserIdsFromEmails = lookupScopedUserIdsByEmails( + scopedUserEmailMaps, + filters.excludedUserEmails + ); + + if (filters.userEmails && filters.userEmails.length > 0) { + prepared.userIds = uniqueStrings([ + ...(filters.userIds ?? []), + ...userIdsFromEmails, + NO_MATCHING_USER_EMAIL_ID, + ]); + } + if (filters.excludedUserEmails && filters.excludedUserEmails.length > 0) { + prepared.excludedUserIds = uniqueStrings([ + ...(filters.excludedUserIds ?? []), + ...excludedUserIdsFromEmails, + ]); + } + return { filters: prepared, scopedUserEmailMaps }; +} + +function userEmailsForDisplay( + filters: UsageAnalyticsFilters, + scopedUserEmailMaps: ScopedUserEmailMaps | undefined +): Map { + return filters.userDisplay === 'email' + ? (scopedUserEmailMaps?.emailsById ?? new Map()) + : new Map(); } // --------------------------------------------------------------------------- @@ -265,6 +521,9 @@ function buildScopeConditions( where.addEq('organization_id', filters.organizationId); if (filters.viewAs === 'self') { where.addEq('kilo_user_id', ctxUserId); + if (filters.excludedUserIds?.includes(ctxUserId)) { + where.addNotIn('kilo_user_id', [ctxUserId]); + } } else { if (filters.userIds && filters.userIds.length > 0) { where.addIn('kilo_user_id', filters.userIds); @@ -275,6 +534,9 @@ function buildScopeConditions( } } else { where.addEq('kilo_user_id', ctxUserId); + if (filters.excludedUserIds?.includes(ctxUserId)) { + where.addNotIn('kilo_user_id', [ctxUserId]); + } if (filters.personalScope === 'personal-only') { // DBT coalesces personal Snowflake usage rollups to an empty-string sentinel // so incremental merges can match on organization_id. @@ -530,6 +792,10 @@ function toSafeNumber(value: unknown): number { return n; } +function toStringValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + // --------------------------------------------------------------------------- // User list (for org context) // --------------------------------------------------------------------------- @@ -591,10 +857,10 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(UsageAnalyticsFiltersSchema) .output(SummaryOutputSchema) .query(async ({ input, ctx }): Promise => { - await ensureScopeAccess(ctx, input); + const { filters } = await prepareUsageFilters(ctx, input); const config = resolveSnowflakeConfig(); - const meta = resolveTier(input.granularity, input.startDate); + const meta = resolveTier(filters.granularity, filters.startDate); if (!config) { return { costMicrodollars: 0, @@ -624,9 +890,9 @@ export const usageAnalyticsRouter = createTRPCRouter({ }; } const table = getTableName(meta.tier); - const where = buildWhereClause(meta.tier, input, ctx.user.id, true); + const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); const generationTimeCountExpr = generationTimeCountExprSql(meta.tier); - const costSumExpr = costSumExprSql(input.costSource); + const costSumExpr = costSumExprSql(filters.costSource); const statement = ` SELECT @@ -654,8 +920,8 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getSummary', queryLabel: `summary_${meta.tier}`, - scope: input.organizationId ? 'org' : 'user', - period: `${input.startDate}/${input.endDate}`, + scope: filters.organizationId ? 'org' : 'user', + period: `${filters.startDate}/${filters.endDate}`, }, signal => executeSnowflakeStatement({ @@ -663,7 +929,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000 ), signal, }) @@ -720,17 +986,21 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(TimeseriesInputSchema) .output(TimeseriesOutputSchema) .query(async ({ input, ctx }) => { - await ensureScopeAccess(ctx, input); + const { filters, scopedUserEmailMaps } = await prepareUsageFilters( + ctx, + input, + input.splitBy === 'user' + ); const config = resolveSnowflakeConfig(); - const meta = resolveTier(input.granularity, input.startDate); + const meta = resolveTier(filters.granularity, filters.startDate); if (!config) { return { timeseries: [], effectiveGranularity: meta.effectiveGranularity }; } const table = getTableName(meta.tier); const bucketExpr = bucketExprSql(meta.effectiveGranularity, meta.tier); - const metricExpr = metricExprSql(input.metric, meta.tier, input.costSource); - const where = buildWhereClause(meta.tier, input, ctx.user.id, true); + const metricExpr = metricExprSql(filters.metric, meta.tier, filters.costSource); + const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); let statement: string; if (input.splitBy) { @@ -761,8 +1031,8 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getTimeseries', queryLabel: `timeseries_${meta.tier}${input.splitBy ? `_split_${input.splitBy}` : ''}`, - scope: input.organizationId ? 'org' : 'user', - period: `${input.startDate}/${input.endDate}`, + scope: filters.organizationId ? 'org' : 'user', + period: `${filters.startDate}/${filters.endDate}`, }, signal => executeSnowflakeStatement({ @@ -770,18 +1040,25 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000 ), signal, }) ); + const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); + return { - timeseries: rows.map(row => ({ - datetime: row[0] ?? '', - value: toSafeNumber(row[1]), - label: input.splitBy ? (row[2] ?? undefined) : undefined, - })), + timeseries: rows.map(row => { + const rawLabel = toStringValue(row[2]); + return { + datetime: toStringValue(row[0]), + value: toSafeNumber(row[1]), + label: input.splitBy + ? (userEmailsById.get(rawLabel) ?? rawLabel) || undefined + : undefined, + }; + }), effectiveGranularity: meta.effectiveGranularity, }; }), @@ -790,17 +1067,21 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(BreakdownInputSchema) .output(BreakdownOutputSchema) .query(async ({ input, ctx }) => { - await ensureScopeAccess(ctx, input); + const { filters, scopedUserEmailMaps } = await prepareUsageFilters( + ctx, + input, + input.dimension === 'user' + ); const config = resolveSnowflakeConfig(); - const meta = resolveTier(input.granularity, input.startDate); + const meta = resolveTier(filters.granularity, filters.startDate); if (!config) { return { breakdown: [], totalValue: 0, effectiveGranularity: meta.effectiveGranularity }; } const table = getTableName(meta.tier); const dimCol = dimensionColumn(input.dimension); - const metricExpr = metricExprSql(input.metric, meta.tier, input.costSource); - const where = buildWhereClause(meta.tier, input, ctx.user.id, true); + const metricExpr = metricExprSql(input.metric, meta.tier, filters.costSource); + const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); const statement = ` SELECT @@ -821,8 +1102,8 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getBreakdown', queryLabel: `breakdown_${meta.tier}_by_${input.dimension}`, - scope: input.organizationId ? 'org' : 'user', - period: `${input.startDate}/${input.endDate}`, + scope: filters.organizationId ? 'org' : 'user', + period: `${filters.startDate}/${filters.endDate}`, }, signal => executeSnowflakeStatement({ @@ -830,13 +1111,14 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000 ), signal, }) ); - const values = rows.map(row => ({ key: row[0] ?? '', value: toSafeNumber(row[1]) })); + const values = rows.map(row => ({ key: toStringValue(row[0]), value: toSafeNumber(row[1]) })); + const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); // Percentages are relative to the *returned* rows (limited by input.limit). // They will not reflect the true share when the result set is capped. const totalValue = values.reduce((s, r) => s + r.value, 0); @@ -844,7 +1126,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ return { breakdown: values.map(r => ({ key: r.key, - label: r.key, + label: userEmailsById.get(r.key) ?? r.key, value: r.value, percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0, })), @@ -857,17 +1139,21 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(TableInputSchema) .output(TableOutputSchema) .query(async ({ input, ctx }) => { - await ensureScopeAccess(ctx, input); + const { filters, scopedUserEmailMaps } = await prepareUsageFilters( + ctx, + input, + input.groupBy.includes('user') + ); const config = resolveSnowflakeConfig(); - const meta = resolveTier(input.granularity, input.startDate); + const meta = resolveTier(filters.granularity, filters.startDate); if (!config) { return { rows: [], effectiveGranularity: meta.effectiveGranularity }; } const table = getTableName(meta.tier); const bucketExpr = bucketExprSql(meta.effectiveGranularity, meta.tier); - const where = buildWhereClause(meta.tier, input, ctx.user.id, true); - const costSumExpr = costSumExprSql(input.costSource); + const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); + const costSumExpr = costSumExprSql(filters.costSource); const requestedDims = input.groupBy; @@ -913,8 +1199,8 @@ export const usageAnalyticsRouter = createTRPCRouter({ { route: 'usageAnalytics.getTable', queryLabel: `table_${meta.tier}_groupby_${requestedDims.join('+') || 'none'}`, - scope: input.organizationId ? 'org' : 'user', - period: `${input.startDate}/${input.endDate}`, + scope: filters.organizationId ? 'org' : 'user', + period: `${filters.startDate}/${filters.endDate}`, }, signal => executeSnowflakeStatement({ @@ -922,7 +1208,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ statement, bindings: where.bindings, timeoutSeconds: Math.ceil( - defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000 + defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000 ), signal, }) @@ -937,15 +1223,18 @@ export const usageAnalyticsRouter = createTRPCRouter({ project: 6, }; + const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); + return { rows: rows.map(row => { const dimensions: Record = {}; for (const d of requestedDims) { const raw = row[dimIndexMap[d]]; - dimensions[d] = typeof raw === 'string' ? raw : ''; + const value = toStringValue(raw); + dimensions[d] = d === 'user' ? (userEmailsById.get(value) ?? value) : value; } return { - datetime: row[0] ?? '', + datetime: toStringValue(row[0]), dimensions, costMicrodollars: toSafeNumber(row[7]), requestCount: toSafeNumber(row[8]), diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts index 0c38fb1f06..021870cf5a 100644 --- a/apps/web/src/routers/usage-analytics-schemas.ts +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -37,14 +37,17 @@ const FiltersShape = { models: z.array(z.string()).optional(), modes: z.array(z.string()).optional(), userIds: z.array(z.string()).optional(), + userEmails: z.array(z.string()).optional(), providers: z.array(z.string()).optional(), projects: z.array(z.string()).optional(), excludedFeatures: z.array(z.string()).optional(), excludedModels: z.array(z.string()).optional(), excludedModes: z.array(z.string()).optional(), excludedUserIds: z.array(z.string()).optional(), + excludedUserEmails: z.array(z.string()).optional(), excludedProviders: z.array(z.string()).optional(), excludedProjects: z.array(z.string()).optional(), + userDisplay: z.enum(['id', 'email']).default('id'), } as const; export const UsageAnalyticsFiltersSchema = z.object(FiltersShape); From 62c590ffd2310f7d35e6ec0cd78ef24d194ada0a Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 09:50:40 -0600 Subject: [PATCH 3/9] fix(usage): hide unmapped user ids in email display --- .../routers/usage-analytics-router.test.ts | 17 ++++++++ .../web/src/routers/usage-analytics-router.ts | 41 ++++++++++++++----- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index e1251f7094..72a9683ac3 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -7,7 +7,9 @@ import { buildScopedUserEmailMaps, costColumnFor, costSumExprSql, + dimensionDisplayValue, shouldLoadFullOrgWideUserEmailMap, + userDisplayValue, } from './usage-analytics-router'; const baseFilters = { @@ -104,4 +106,19 @@ describe('usage analytics cost source', () => { true ); }); + + it('does not fall back to raw user ids for unmapped email display values', () => { + const userEmailsById = new Map([['user_1', 'person@example.com']]); + + expect(userDisplayValue({ userDisplay: 'email' }, userEmailsById, 'user_1')).toBe( + 'person@example.com' + ); + expect(userDisplayValue({ userDisplay: 'email' }, userEmailsById, 'missing_user')).toBe(''); + expect(userDisplayValue({ userDisplay: 'id' }, userEmailsById, 'missing_user')).toBe( + 'missing_user' + ); + expect(dimensionDisplayValue('model', { userDisplay: 'email' }, userEmailsById, 'claude')).toBe( + 'claude' + ); + }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 5a606f4b34..fcc6a22716 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -483,6 +483,23 @@ function userEmailsForDisplay( : new Map(); } +export function userDisplayValue( + filters: Pick, + userEmailsById: Map, + rawUserId: string +): string { + return filters.userDisplay === 'email' ? (userEmailsById.get(rawUserId) ?? '') : rawUserId; +} + +export function dimensionDisplayValue( + dimension: Dimension, + filters: Pick, + userEmailsById: Map, + rawValue: string +): string { + return dimension === 'user' ? userDisplayValue(filters, userEmailsById, rawValue) : rawValue; +} + // --------------------------------------------------------------------------- // WHERE clause helpers // --------------------------------------------------------------------------- @@ -1051,12 +1068,13 @@ export const usageAnalyticsRouter = createTRPCRouter({ return { timeseries: rows.map(row => { const rawLabel = toStringValue(row[2]); + const label = input.splitBy + ? dimensionDisplayValue(input.splitBy, filters, userEmailsById, rawLabel) + : undefined; return { datetime: toStringValue(row[0]), value: toSafeNumber(row[1]), - label: input.splitBy - ? (userEmailsById.get(rawLabel) ?? rawLabel) || undefined - : undefined, + label: label || undefined, }; }), effectiveGranularity: meta.effectiveGranularity, @@ -1124,12 +1142,15 @@ export const usageAnalyticsRouter = createTRPCRouter({ const totalValue = values.reduce((s, r) => s + r.value, 0); return { - breakdown: values.map(r => ({ - key: r.key, - label: userEmailsById.get(r.key) ?? r.key, - value: r.value, - percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0, - })), + breakdown: values.map(r => { + const key = dimensionDisplayValue(input.dimension, filters, userEmailsById, r.key); + return { + key, + label: key, + value: r.value, + percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0, + }; + }), totalValue, effectiveGranularity: meta.effectiveGranularity, }; @@ -1231,7 +1252,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ for (const d of requestedDims) { const raw = row[dimIndexMap[d]]; const value = toStringValue(raw); - dimensions[d] = d === 'user' ? (userEmailsById.get(value) ?? value) : value; + dimensions[d] = dimensionDisplayValue(d, filters, userEmailsById, value); } return { datetime: toStringValue(row[0]), From ce4959d7e3144a99fb4d50e285c7dd5cd86fc60d Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 10:04:09 -0600 Subject: [PATCH 4/9] fix(usage): aggregate email breakdown buckets --- .../organizations/[id]/members/route.test.ts | 3 +- .../routers/usage-analytics-router.test.ts | 28 ++++++++++++ .../web/src/routers/usage-analytics-router.ts | 44 ++++++++++++++++--- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts b/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts index f198e28d0e..de2734db58 100644 --- a/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts +++ b/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it, jest } from '@jest/globals'; import { NextRequest } from 'next/server'; import type { handleTRPCRequest } from '@/lib/trpc-route-handler'; +import type { GET as routeGET } from './route'; jest.mock('@/lib/trpc-route-handler', () => ({ handleTRPCRequest: jest.fn(), @@ -12,7 +13,7 @@ const { handleTRPCRequest: mockedHandleTRPCRequest } = jest.requireMock( handleTRPCRequest: jest.MockedFunction; }; -let GET: typeof import('./route').GET; +let GET: typeof routeGET; beforeAll(async () => { ({ GET } = await import('./route')); diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index 72a9683ac3..b133bd3c57 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -1,6 +1,7 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); import { + aggregateDisplayedBreakdownValues, CostSourceSchema, UsageAnalyticsFiltersSchema, applySelfEmailExclusion, @@ -121,4 +122,31 @@ describe('usage analytics cost source', () => { 'claude' ); }); + + it('coalesces breakdown values by displayed user email before limiting', () => { + const userEmailsById = new Map([ + ['user_1', 'person@example.com'], + ['oauth/github:123', 'person@example.com'], + ['user_2', 'other@example.com'], + ]); + + expect( + aggregateDisplayedBreakdownValues( + 'user', + { userDisplay: 'email' }, + userEmailsById, + [ + { key: 'user_2', value: 80 }, + { key: 'user_1', value: 60 }, + { key: 'oauth/github:123', value: 50 }, + { key: 'missing_user_1', value: 6 }, + { key: 'missing_user_2', value: 4 }, + ], + 2 + ) + ).toEqual([ + { key: 'person@example.com', value: 110 }, + { key: 'other@example.com', value: 80 }, + ]); + }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index fcc6a22716..9c9c65650f 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -500,6 +500,30 @@ export function dimensionDisplayValue( return dimension === 'user' ? userDisplayValue(filters, userEmailsById, rawValue) : rawValue; } +type BreakdownValue = { + key: string; + value: number; +}; + +export function aggregateDisplayedBreakdownValues( + dimension: Dimension, + filters: Pick, + userEmailsById: Map, + values: BreakdownValue[], + limit: number +): BreakdownValue[] { + const totalsByKey = new Map(); + + for (const value of values) { + const key = dimensionDisplayValue(dimension, filters, userEmailsById, value.key); + totalsByKey.set(key, (totalsByKey.get(key) ?? 0) + value.value); + } + + return Array.from(totalsByKey, ([key, value]) => ({ key, value })) + .sort((a, b) => b.value - a.value) + .slice(0, limit); +} + // --------------------------------------------------------------------------- // WHERE clause helpers // --------------------------------------------------------------------------- @@ -1100,6 +1124,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ const dimCol = dimensionColumn(input.dimension); const metricExpr = metricExprSql(input.metric, meta.tier, filters.costSource); const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); + const aggregateByDisplayedKey = input.dimension === 'user' && filters.userDisplay === 'email'; const statement = ` SELECT @@ -1109,7 +1134,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ WHERE ${where.sql()} GROUP BY 1 ORDER BY 2 DESC - LIMIT ${Number(input.limit)} + ${aggregateByDisplayedKey ? '' : `LIMIT ${Number(input.limit)}`} `; // SAFETY: LIMIT value is interpolated directly into SQL but is @@ -1135,18 +1160,27 @@ export const usageAnalyticsRouter = createTRPCRouter({ }) ); - const values = rows.map(row => ({ key: toStringValue(row[0]), value: toSafeNumber(row[1]) })); + const rawValues = rows.map(row => ({ + key: toStringValue(row[0]), + value: toSafeNumber(row[1]), + })); const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); + const values = aggregateDisplayedBreakdownValues( + input.dimension, + filters, + userEmailsById, + rawValues, + input.limit + ); // Percentages are relative to the *returned* rows (limited by input.limit). // They will not reflect the true share when the result set is capped. const totalValue = values.reduce((s, r) => s + r.value, 0); return { breakdown: values.map(r => { - const key = dimensionDisplayValue(input.dimension, filters, userEmailsById, r.key); return { - key, - label: key, + key: r.key, + label: r.key, value: r.value, percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0, }; From 52c45305a87b4b39e7d7de23f2ba9eae8437f79f Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 10:26:52 -0600 Subject: [PATCH 5/9] fix(usage): preserve limits for email breakdowns --- .../routers/usage-analytics-router.test.ts | 8 +++---- .../web/src/routers/usage-analytics-router.ts | 21 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index b133bd3c57..526d81e5c7 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -1,7 +1,6 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); import { - aggregateDisplayedBreakdownValues, CostSourceSchema, UsageAnalyticsFiltersSchema, applySelfEmailExclusion, @@ -9,6 +8,7 @@ import { costColumnFor, costSumExprSql, dimensionDisplayValue, + displayBreakdownValues, shouldLoadFullOrgWideUserEmailMap, userDisplayValue, } from './usage-analytics-router'; @@ -123,7 +123,7 @@ describe('usage analytics cost source', () => { ); }); - it('coalesces breakdown values by displayed user email before limiting', () => { + it('maps breakdown user ids to emails without changing Snowflake buckets', () => { const userEmailsById = new Map([ ['user_1', 'person@example.com'], ['oauth/github:123', 'person@example.com'], @@ -131,7 +131,7 @@ describe('usage analytics cost source', () => { ]); expect( - aggregateDisplayedBreakdownValues( + displayBreakdownValues( 'user', { userDisplay: 'email' }, userEmailsById, @@ -145,8 +145,8 @@ describe('usage analytics cost source', () => { 2 ) ).toEqual([ - { key: 'person@example.com', value: 110 }, { key: 'other@example.com', value: 80 }, + { key: 'person@example.com', value: 60 }, ]); }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 9c9c65650f..72d05faf77 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -505,23 +505,17 @@ type BreakdownValue = { value: number; }; -export function aggregateDisplayedBreakdownValues( +export function displayBreakdownValues( dimension: Dimension, filters: Pick, userEmailsById: Map, values: BreakdownValue[], limit: number ): BreakdownValue[] { - const totalsByKey = new Map(); - - for (const value of values) { - const key = dimensionDisplayValue(dimension, filters, userEmailsById, value.key); - totalsByKey.set(key, (totalsByKey.get(key) ?? 0) + value.value); - } - - return Array.from(totalsByKey, ([key, value]) => ({ key, value })) - .sort((a, b) => b.value - a.value) - .slice(0, limit); + return values.slice(0, limit).map(value => ({ + key: dimensionDisplayValue(dimension, filters, userEmailsById, value.key), + value: value.value, + })); } // --------------------------------------------------------------------------- @@ -1124,7 +1118,6 @@ export const usageAnalyticsRouter = createTRPCRouter({ const dimCol = dimensionColumn(input.dimension); const metricExpr = metricExprSql(input.metric, meta.tier, filters.costSource); const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); - const aggregateByDisplayedKey = input.dimension === 'user' && filters.userDisplay === 'email'; const statement = ` SELECT @@ -1134,7 +1127,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ WHERE ${where.sql()} GROUP BY 1 ORDER BY 2 DESC - ${aggregateByDisplayedKey ? '' : `LIMIT ${Number(input.limit)}`} + LIMIT ${Number(input.limit)} `; // SAFETY: LIMIT value is interpolated directly into SQL but is @@ -1165,7 +1158,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ value: toSafeNumber(row[1]), })); const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); - const values = aggregateDisplayedBreakdownValues( + const values = displayBreakdownValues( input.dimension, filters, userEmailsById, From e2e18a2ee00d3bedef2fba7ee08149d0baa96631 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 10:43:53 -0600 Subject: [PATCH 6/9] fix(usage): keep raw breakdown keys --- apps/web/src/routers/usage-analytics-router.test.ts | 4 ++-- apps/web/src/routers/usage-analytics-router.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index 526d81e5c7..b89833f525 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -145,8 +145,8 @@ describe('usage analytics cost source', () => { 2 ) ).toEqual([ - { key: 'other@example.com', value: 80 }, - { key: 'person@example.com', value: 60 }, + { key: 'user_2', label: 'other@example.com', value: 80 }, + { key: 'user_1', label: 'person@example.com', value: 60 }, ]); }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 72d05faf77..1e56363c20 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -502,6 +502,7 @@ export function dimensionDisplayValue( type BreakdownValue = { key: string; + label: string; value: number; }; @@ -513,7 +514,8 @@ export function displayBreakdownValues( limit: number ): BreakdownValue[] { return values.slice(0, limit).map(value => ({ - key: dimensionDisplayValue(dimension, filters, userEmailsById, value.key), + key: value.key, + label: dimensionDisplayValue(dimension, filters, userEmailsById, value.key), value: value.value, })); } @@ -1155,6 +1157,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ const rawValues = rows.map(row => ({ key: toStringValue(row[0]), + label: toStringValue(row[0]), value: toSafeNumber(row[1]), })); const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); @@ -1173,7 +1176,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ breakdown: values.map(r => { return { key: r.key, - label: r.key, + label: r.label, value: r.value, percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0, }; From 4db69edcb11705ce747286121b0c4822e8ca8e28 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 11:02:19 -0600 Subject: [PATCH 7/9] fix(usage): aggregate scoped email breakdowns --- .../routers/usage-analytics-router.test.ts | 47 +++++++++++--- .../web/src/routers/usage-analytics-router.ts | 61 ++++++++++++++++--- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index b89833f525..7485409fe4 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -9,6 +9,8 @@ import { costSumExprSql, dimensionDisplayValue, displayBreakdownValues, + scopedUserEmailBreakdownIds, + shouldLimitBreakdownInSql, shouldLoadFullOrgWideUserEmailMap, userDisplayValue, } from './usage-analytics-router'; @@ -123,7 +125,7 @@ describe('usage analytics cost source', () => { ); }); - it('maps breakdown user ids to emails without changing Snowflake buckets', () => { + it('aggregates breakdown user ids by email before applying the display limit', () => { const userEmailsById = new Map([ ['user_1', 'person@example.com'], ['oauth/github:123', 'person@example.com'], @@ -136,17 +138,46 @@ describe('usage analytics cost source', () => { { userDisplay: 'email' }, userEmailsById, [ - { key: 'user_2', value: 80 }, - { key: 'user_1', value: 60 }, - { key: 'oauth/github:123', value: 50 }, - { key: 'missing_user_1', value: 6 }, - { key: 'missing_user_2', value: 4 }, + { key: 'user_2', label: 'user_2', value: 80 }, + { key: 'user_1', label: 'user_1', value: 60 }, + { key: 'oauth/github:123', label: 'oauth/github:123', value: 50 }, + { key: 'missing_user_1', label: 'missing_user_1', value: 6 }, + { key: 'missing_user_2', label: 'missing_user_2', value: 4 }, ], 2 ) ).toEqual([ - { key: 'user_2', label: 'other@example.com', value: 80 }, - { key: 'user_1', label: 'person@example.com', value: 60 }, + { key: 'person@example.com', label: 'person@example.com', value: 110 }, + { key: 'other@example.com', label: 'other@example.com', value: 80 }, ]); }); + + it('does not apply the Snowflake breakdown limit before email aggregation', () => { + expect(shouldLimitBreakdownInSql('user', { userDisplay: 'email' })).toBe(false); + expect(shouldLimitBreakdownInSql('user', { userDisplay: 'id' })).toBe(true); + expect(shouldLimitBreakdownInSql('model', { userDisplay: 'email' })).toBe(true); + }); + + it('scopes email-display user breakdown queries to mapped user identities', () => { + const maps = buildScopedUserEmailMaps( + [ + { id: 'user_1', email: 'person@example.com' }, + { id: 'user_2', email: 'other@example.com' }, + ], + [ + { userId: 'user_1', provider: 'github', providerAccountId: '123' }, + { userId: 'user_1', provider: 'github', providerAccountId: '123' }, + { userId: 'user_2', provider: 'google', providerAccountId: 'abc' }, + ] + ); + + expect(scopedUserEmailBreakdownIds('user', { userDisplay: 'email' }, maps)).toEqual([ + 'user_1', + 'oauth/github:123', + 'user_2', + 'oauth/google:abc', + ]); + expect(scopedUserEmailBreakdownIds('user', { userDisplay: 'id' }, maps)).toBe(undefined); + expect(scopedUserEmailBreakdownIds('model', { userDisplay: 'email' }, maps)).toBe(undefined); + }); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 1e56363c20..ab55088234 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -513,11 +513,42 @@ export function displayBreakdownValues( values: BreakdownValue[], limit: number ): BreakdownValue[] { - return values.slice(0, limit).map(value => ({ - key: value.key, - label: dimensionDisplayValue(dimension, filters, userEmailsById, value.key), - value: value.value, - })); + const valuesByDisplayKey = new Map(); + + for (const value of values) { + const label = dimensionDisplayValue(dimension, filters, userEmailsById, value.key); + const displayKey = dimension === 'user' && filters.userDisplay === 'email' ? label : value.key; + const existing = valuesByDisplayKey.get(displayKey); + if (existing) { + existing.value += value.value; + continue; + } + valuesByDisplayKey.set(displayKey, { + key: displayKey, + label, + value: value.value, + }); + } + + return Array.from(valuesByDisplayKey.values()) + .sort((a, b) => b.value - a.value) + .slice(0, limit); +} + +export function shouldLimitBreakdownInSql( + dimension: Dimension, + filters: Pick +): boolean { + return !(dimension === 'user' && filters.userDisplay === 'email'); +} + +export function scopedUserEmailBreakdownIds( + dimension: Dimension, + filters: Pick, + scopedUserEmailMaps: ScopedUserEmailMaps | undefined +): string[] | undefined { + if (dimension !== 'user' || filters.userDisplay !== 'email') return undefined; + return uniqueStrings(Array.from(scopedUserEmailMaps?.idsByEmail.values() ?? []).flat()); } // --------------------------------------------------------------------------- @@ -1120,6 +1151,20 @@ export const usageAnalyticsRouter = createTRPCRouter({ const dimCol = dimensionColumn(input.dimension); const metricExpr = metricExprSql(input.metric, meta.tier, filters.costSource); const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); + const limitBreakdownInSql = shouldLimitBreakdownInSql(input.dimension, filters); + const scopedBreakdownUserIds = scopedUserEmailBreakdownIds( + input.dimension, + filters, + scopedUserEmailMaps + ); + + if (scopedBreakdownUserIds) { + if (scopedBreakdownUserIds.length > 0) { + where.addIn('kilo_user_id', scopedBreakdownUserIds); + } else { + where.addEq('kilo_user_id', NO_MATCHING_USER_EMAIL_ID); + } + } const statement = ` SELECT @@ -1129,11 +1174,11 @@ export const usageAnalyticsRouter = createTRPCRouter({ WHERE ${where.sql()} GROUP BY 1 ORDER BY 2 DESC - LIMIT ${Number(input.limit)} + ${limitBreakdownInSql ? `LIMIT ${Number(input.limit)}` : ''} `; - // SAFETY: LIMIT value is interpolated directly into SQL but is - // validated by Zod above: `z.number().int().min(1).max(10_000)`. + // SAFETY: LIMIT value is interpolated directly into SQL when present but + // is validated by Zod above: `z.number().int().min(1).max(100)`. // Snowflake's SQL API v2 does not support parameter binding for LIMIT. const rows = await timedSnowflakeQuery( From e022ad4f93f9a2de4e7cb4900af06e84ad37e8b4 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 11:51:27 -0600 Subject: [PATCH 8/9] fix(usage): bound email breakdown aggregation --- .../routers/usage-analytics-router.test.ts | 176 +++++++++++++++++- .../web/src/routers/usage-analytics-router.ts | 124 ++++++++---- 2 files changed, 254 insertions(+), 46 deletions(-) diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts index 7485409fe4..c1c4225101 100644 --- a/apps/web/src/routers/usage-analytics-router.test.ts +++ b/apps/web/src/routers/usage-analytics-router.test.ts @@ -1,18 +1,33 @@ jest.mock('@/lib/redis', () => ({ redisClient: {} })); +jest.mock('@/lib/snowflake', () => ({ + executeSnowflakeStatement: jest.fn(), + resolveSnowflakeConfig: jest.fn(() => ({ account: 'test-account' })), +})); + +jest.mock('@/lib/drizzle', () => ({ + readDb: { select: jest.fn() }, +})); + +import type { User } from '@kilocode/db/schema'; +import { readDb } from '@/lib/drizzle'; +import { executeSnowflakeStatement } from '@/lib/snowflake'; +import { createCallerFactory } from '@/lib/trpc/init'; import { CostSourceSchema, UsageAnalyticsFiltersSchema, applySelfEmailExclusion, + buildWhereClause, buildScopedUserEmailMaps, costColumnFor, costSumExprSql, dimensionDisplayValue, displayBreakdownValues, scopedUserEmailBreakdownIds, - shouldLimitBreakdownInSql, shouldLoadFullOrgWideUserEmailMap, + userEmailMapValuesSql, userDisplayValue, + usageAnalyticsRouter, } from './usage-analytics-router'; const baseFilters = { @@ -21,7 +36,37 @@ const baseFilters = { granularity: 'day' as const, }; +const createCaller = createCallerFactory(usageAnalyticsRouter); +const mockExecuteSnowflakeStatement = jest.mocked(executeSnowflakeStatement); +const mockReadDbSelect = jest.mocked(readDb.select); +const mockAuthProviderRows: Array<{ + userId: string; + provider: 'github' | 'google'; + providerAccountId: string; +}> = []; + +function createUsageAnalyticsCaller() { + return createCaller({ + user: { + id: 'user_1', + google_user_email: 'person@example.com', + is_admin: false, + } as User, + }); +} + describe('usage analytics cost source', () => { + beforeEach(() => { + mockExecuteSnowflakeStatement.mockReset(); + mockExecuteSnowflakeStatement.mockResolvedValue([['person@example.com', '110']]); + mockAuthProviderRows.splice(0, mockAuthProviderRows.length); + mockReadDbSelect.mockReturnValue({ + from: jest.fn(() => ({ + where: jest.fn(async () => mockAuthProviderRows), + })), + } as never); + }); + it('defaults to billable cost for existing clients', () => { expect(UsageAnalyticsFiltersSchema.parse(baseFilters).costSource).toBe('cost'); expect(costColumnFor('cost')).toBe('total_cost_microdollars'); @@ -152,10 +197,131 @@ describe('usage analytics cost source', () => { ]); }); - it('does not apply the Snowflake breakdown limit before email aggregation', () => { - expect(shouldLimitBreakdownInSql('user', { userDisplay: 'email' })).toBe(false); - expect(shouldLimitBreakdownInSql('user', { userDisplay: 'id' })).toBe(true); - expect(shouldLimitBreakdownInSql('model', { userDisplay: 'email' })).toBe(true); + it('builds bound SQL values for email-display user aggregation', () => { + const maps = buildScopedUserEmailMaps( + [ + { id: 'user_1', email: 'person@example.com' }, + { id: 'user_2', email: 'other@example.com' }, + ], + [{ userId: 'user_1', provider: 'github', providerAccountId: '123' }] + ); + + const values = userEmailMapValuesSql(maps); + + expect(values?.valuesSql).toBe('(?, ?), (?, ?), (?, ?)'); + expect(values?.valuesSql).not.toContain('person@example.com'); + expect(values?.valuesSql).not.toContain('oauth/github:123'); + expect(values?.bindings).toEqual([ + { type: 'TEXT', value: 'user_1' }, + { type: 'TEXT', value: 'person@example.com' }, + { type: 'TEXT', value: 'user_2' }, + { type: 'TEXT', value: 'other@example.com' }, + { type: 'TEXT', value: 'oauth/github:123' }, + { type: 'TEXT', value: 'person@example.com' }, + ]); + }); + + it('getBreakdown aggregates email-display user buckets in SQL before limiting', async () => { + mockAuthProviderRows.push({ + userId: 'user_1', + provider: 'github', + providerAccountId: '123', + }); + + const caller = createUsageAnalyticsCaller(); + + await caller.getBreakdown({ + ...baseFilters, + dimension: 'user', + metric: 'cost', + userDisplay: 'email', + limit: 2, + }); + + expect(mockExecuteSnowflakeStatement).toHaveBeenCalledTimes(1); + const statement = mockExecuteSnowflakeStatement.mock.calls[0][0].statement as string; + expect(statement).toContain('WITH user_email_map(mapped_user_id, mapped_email) AS'); + expect(statement).toContain('FROM VALUES (?, ?), (?, ?)'); + expect(statement).toContain('JOIN user_email_map ON kilo_user_id = mapped_user_id'); + expect(statement).toContain('mapped_email AS key'); + expect(statement).toContain('GROUP BY 1'); + expect(statement).toContain('ORDER BY 2 DESC'); + expect(statement).toContain('LIMIT 2'); + expect(statement).not.toContain('person@example.com'); + expect(statement).not.toContain('oauth/github:123'); + }); + + it('getBreakdown wires scoped self email identities into the self-scope predicate', async () => { + mockAuthProviderRows.push({ + userId: 'user_1', + provider: 'github', + providerAccountId: '123', + }); + + const caller = createUsageAnalyticsCaller(); + + await caller.getBreakdown({ + ...baseFilters, + dimension: 'user', + metric: 'cost', + userDisplay: 'email', + limit: 2, + }); + + const call = mockExecuteSnowflakeStatement.mock.calls[0][0]; + const statement = call.statement as string; + expect(statement).toContain('kilo_user_id IN (?, ?)'); + expect(statement).not.toContain('kilo_user_id = ?'); + expect(call.bindings).toEqual([ + { type: 'TEXT', value: 'user_1' }, + { type: 'TEXT', value: 'person@example.com' }, + { type: 'TEXT', value: 'oauth/github:123' }, + { type: 'TEXT', value: 'person@example.com' }, + { type: 'TEXT', value: '2026-06-04' }, + { type: 'TEXT', value: '2026-06-05' }, + { type: 'TEXT', value: 'user_1' }, + { type: 'TEXT', value: 'oauth/github:123' }, + { type: 'TEXT', value: '' }, + ]); + }); + + it('uses scoped self ids instead of intersecting email breakdowns with the canonical user id', () => { + const filters = UsageAnalyticsFiltersSchema.parse({ + ...baseFilters, + userDisplay: 'email', + }); + + const where = buildWhereClause('daily', filters, 'user_1', true, [ + 'user_1', + 'oauth/github:123', + ]); + + expect(where.sql()).toContain('kilo_user_id IN (?, ?)'); + expect(where.sql()).not.toContain('kilo_user_id = ?'); + expect(where.bindings.map(binding => binding.value)).toEqual( + expect.arrayContaining(['user_1', 'oauth/github:123']) + ); + }); + + it('uses scoped self ids for organization self-scope email breakdowns', () => { + const filters = UsageAnalyticsFiltersSchema.parse({ + ...baseFilters, + organizationId: '00000000-0000-4000-8000-000000000001', + viewAs: 'self', + userDisplay: 'email', + }); + + const where = buildWhereClause('daily', filters, 'user_1', true, [ + 'user_1', + 'oauth/github:123', + ]); + + expect(where.sql()).toContain('organization_id = ?'); + expect(where.sql()).toContain('kilo_user_id IN (?, ?)'); + expect(where.sql()).not.toContain('kilo_user_id = ?'); + expect(where.bindings.map(binding => binding.value)).toEqual( + expect.arrayContaining(['00000000-0000-4000-8000-000000000001', 'user_1', 'oauth/github:123']) + ); }); it('scopes email-display user breakdown queries to mapped user identities', () => { diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index ab55088234..f9e485ef8d 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -137,7 +137,7 @@ function ceilIsoToUtcMonthExclusive(iso: string): string { * Accumulates SQL WHERE clauses with positional `?` bindings. * Callers push conditions in any order; `sql()` joins them with AND. */ -class WhereBuilder { +export class WhereBuilder { readonly clauses: string[] = []; readonly bindings: SnowflakeBinding[] = []; @@ -535,13 +535,6 @@ export function displayBreakdownValues( .slice(0, limit); } -export function shouldLimitBreakdownInSql( - dimension: Dimension, - filters: Pick -): boolean { - return !(dimension === 'user' && filters.userDisplay === 'email'); -} - export function scopedUserEmailBreakdownIds( dimension: Dimension, filters: Pick, @@ -551,6 +544,23 @@ export function scopedUserEmailBreakdownIds( return uniqueStrings(Array.from(scopedUserEmailMaps?.idsByEmail.values() ?? []).flat()); } +export function userEmailMapValuesSql( + scopedUserEmailMaps: ScopedUserEmailMaps | undefined +): { valuesSql: string; bindings: SnowflakeBinding[] } | undefined { + const entries = Array.from(scopedUserEmailMaps?.emailsById.entries() ?? []); + if (entries.length === 0) return undefined; + + const bindings: SnowflakeBinding[] = []; + for (const [userId, email] of entries) { + bindings.push({ type: 'TEXT', value: userId }, { type: 'TEXT', value: email }); + } + + return { + valuesSql: entries.map(() => '(?, ?)').join(', '), + bindings, + }; +} + // --------------------------------------------------------------------------- // WHERE clause helpers // --------------------------------------------------------------------------- @@ -583,15 +593,26 @@ function buildDateConditions( function buildScopeConditions( where: WhereBuilder, filters: UsageAnalyticsFilters, - ctxUserId: string + ctxUserId: string, + scopedSelfUserIds?: string[] ): void { + const selfUserIds = + scopedSelfUserIds && scopedSelfUserIds.length > 0 ? scopedSelfUserIds : undefined; + const addSelfScope = () => { + if (selfUserIds) { + where.addIn('kilo_user_id', selfUserIds); + } else { + where.addEq('kilo_user_id', ctxUserId); + } + if (filters.excludedUserIds?.includes(ctxUserId)) { + where.addNotIn('kilo_user_id', selfUserIds ?? [ctxUserId]); + } + }; + if (filters.organizationId) { where.addEq('organization_id', filters.organizationId); if (filters.viewAs === 'self') { - where.addEq('kilo_user_id', ctxUserId); - if (filters.excludedUserIds?.includes(ctxUserId)) { - where.addNotIn('kilo_user_id', [ctxUserId]); - } + addSelfScope(); } else { if (filters.userIds && filters.userIds.length > 0) { where.addIn('kilo_user_id', filters.userIds); @@ -601,10 +622,7 @@ function buildScopeConditions( } } } else { - where.addEq('kilo_user_id', ctxUserId); - if (filters.excludedUserIds?.includes(ctxUserId)) { - where.addNotIn('kilo_user_id', [ctxUserId]); - } + addSelfScope(); if (filters.personalScope === 'personal-only') { // DBT coalesces personal Snowflake usage rollups to an empty-string sentinel // so incremental merges can match on organization_id. @@ -633,15 +651,16 @@ function buildDimensionConditions(where: WhereBuilder, filters: UsageAnalyticsFi addNotInIfNonEmpty('project_id', filters.excludedProjects); } -function buildWhereClause( +export function buildWhereClause( tier: GranularityTier, filters: UsageAnalyticsFilters, ctxUserId: string, - includeDimensions: boolean + includeDimensions: boolean, + scopedSelfUserIds?: string[] ): WhereBuilder { const where = new WhereBuilder(); buildDateConditions(where, tier, filters); - buildScopeConditions(where, filters, ctxUserId); + buildScopeConditions(where, filters, ctxUserId, scopedSelfUserIds); if (includeDimensions) { buildDimensionConditions(where, filters); } @@ -1150,15 +1169,26 @@ export const usageAnalyticsRouter = createTRPCRouter({ const table = getTableName(meta.tier); const dimCol = dimensionColumn(input.dimension); const metricExpr = metricExprSql(input.metric, meta.tier, filters.costSource); - const where = buildWhereClause(meta.tier, filters, ctx.user.id, true); - const limitBreakdownInSql = shouldLimitBreakdownInSql(input.dimension, filters); const scopedBreakdownUserIds = scopedUserEmailBreakdownIds( input.dimension, filters, scopedUserEmailMaps ); + const scopedSelfUserIds = + scopedBreakdownUserIds && (filters.viewAs === 'self' || !filters.organizationId) + ? scopedBreakdownUserIds + : undefined; + const where = buildWhereClause(meta.tier, filters, ctx.user.id, true, scopedSelfUserIds); + const emailMapValues = + input.dimension === 'user' && filters.userDisplay === 'email' + ? userEmailMapValuesSql(scopedUserEmailMaps) + : undefined; + + if (input.dimension === 'user' && filters.userDisplay === 'email' && !emailMapValues) { + return { breakdown: [], totalValue: 0, effectiveGranularity: meta.effectiveGranularity }; + } - if (scopedBreakdownUserIds) { + if (scopedBreakdownUserIds && !emailMapValues && !scopedSelfUserIds) { if (scopedBreakdownUserIds.length > 0) { where.addIn('kilo_user_id', scopedBreakdownUserIds); } else { @@ -1166,16 +1196,32 @@ export const usageAnalyticsRouter = createTRPCRouter({ } } - const statement = ` - SELECT - ${dimCol} AS key, - ${metricExpr} AS value - FROM ${table} - WHERE ${where.sql()} - GROUP BY 1 - ORDER BY 2 DESC - ${limitBreakdownInSql ? `LIMIT ${Number(input.limit)}` : ''} - `; + const statement = emailMapValues + ? ` + WITH user_email_map(mapped_user_id, mapped_email) AS ( + SELECT column1, column2 + FROM VALUES ${emailMapValues.valuesSql} + ) + SELECT + mapped_email AS key, + ${metricExpr} AS value + FROM ${table} + JOIN user_email_map ON kilo_user_id = mapped_user_id + WHERE ${where.sql()} + GROUP BY 1 + ORDER BY 2 DESC + LIMIT ${Number(input.limit)} + ` + : ` + SELECT + ${dimCol} AS key, + ${metricExpr} AS value + FROM ${table} + WHERE ${where.sql()} + GROUP BY 1 + ORDER BY 2 DESC + LIMIT ${Number(input.limit)} + `; // SAFETY: LIMIT value is interpolated directly into SQL when present but // is validated by Zod above: `z.number().int().min(1).max(100)`. @@ -1192,7 +1238,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ executeSnowflakeStatement({ config, statement, - bindings: where.bindings, + bindings: [...(emailMapValues?.bindings ?? []), ...where.bindings], timeoutSeconds: Math.ceil( defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000 ), @@ -1206,13 +1252,9 @@ export const usageAnalyticsRouter = createTRPCRouter({ value: toSafeNumber(row[1]), })); const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps); - const values = displayBreakdownValues( - input.dimension, - filters, - userEmailsById, - rawValues, - input.limit - ); + const values = emailMapValues + ? rawValues + : displayBreakdownValues(input.dimension, filters, userEmailsById, rawValues, input.limit); // Percentages are relative to the *returned* rows (limited by input.limit). // They will not reflect the true share when the result set is capped. const totalValue = values.reduce((s, r) => s + r.value, 0); From c9c884a15c5edb49b1c5cc43783c88f92eb8d643 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Tue, 23 Jun 2026 12:34:56 -0600 Subject: [PATCH 9/9] docs(api): document organization members endpoint --- apps/web/src/app/api/docs/route.test.ts | 2 + apps/web/src/app/api/docs/route.ts | 2 +- apps/web/src/lib/openapi/trpc-openapi.test.ts | 61 ++++++++++++- apps/web/src/lib/openapi/trpc-openapi.ts | 88 ++++++++++++++++++- apps/web/src/lib/openapi/trpc-registry.ts | 38 +++++++- 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/api/docs/route.test.ts b/apps/web/src/app/api/docs/route.test.ts index e0081f7c0d..b07a0a0144 100644 --- a/apps/web/src/app/api/docs/route.test.ts +++ b/apps/web/src/app/api/docs/route.test.ts @@ -13,5 +13,7 @@ describe('GET /api/docs', () => { expect(body).toContain('.swagger-ui .auth-wrapper'); expect(body).toContain('.swagger-ui .authorization__btn'); expect(body).toContain('display: none !important'); + expect(body).toContain('Swagger UI generated from the Kilo Code OpenAPI document.'); + expect(body).not.toContain('tRPC OpenAPI document'); }); }); diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts index 6e98255c98..9592662d4b 100644 --- a/apps/web/src/app/api/docs/route.ts +++ b/apps/web/src/app/api/docs/route.ts @@ -77,7 +77,7 @@ function swaggerUiHtml(nonce: string) {

Kilo Code API Docs

-

Swagger UI generated from the allowlisted tRPC OpenAPI document.

+

Swagger UI generated from the Kilo Code OpenAPI document.

Open JSON
diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts index 55da2672f2..491e887f5a 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.test.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from '@jest/globals'; import { TRPCError, initTRPC } from '@trpc/server'; import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import * as z from 'zod'; -import { publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry'; +import { publicRestOpenApiRoutes, publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry'; import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi'; import { TrpcErrorResponseSchema, @@ -41,11 +41,14 @@ async function callVerificationProcedure(path: string, input?: unknown): Promise } describe('generateTrpcOpenApiDocument', () => { - it('documents only the allowlisted tRPC procedures', () => { + it('documents only the allowlisted API paths', () => { const document = generateTrpcOpenApiDocument(); expect(Object.keys(document.paths).sort()).toEqual( - publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`).sort() + [ + ...publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`), + ...publicRestOpenApiRoutes.map(route => route.path), + ].sort() ); expect(document.paths['/api/trpc/usageAnalytics.getTable']?.get).toMatchObject({ operationId: 'usageAnalytics_getTable', @@ -55,6 +58,58 @@ describe('generateTrpcOpenApiDocument', () => { expect(document.paths).not.toHaveProperty('/api/trpc/admin'); }); + it('generates the REST organization members endpoint', () => { + const document = generateTrpcOpenApiDocument(); + const operation = document.paths['/api/v1/organizations/{id}/members']?.get as { + responses: Record }> }>; + }; + const responseSchema = operation?.responses['200'].content['application/json'].schema; + + expect(document.info.title).toBe('Kilo Code API'); + expect(operation).toMatchObject({ + operationId: 'organizations_getMembers', + summary: 'Return organization members', + tags: ['Organizations'], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'array', + }, + }, + }, + }, + }, + }); + expect(responseSchema.items.oneOf).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + required: expect.arrayContaining(['id', 'name', 'email', 'status']), + properties: expect.objectContaining({ + status: { type: 'string', const: 'active' }, + }), + }), + expect.objectContaining({ + required: expect.arrayContaining(['email', 'status']), + properties: expect.objectContaining({ + status: { type: 'string', const: 'invited' }, + }), + }), + ]) + ); + expect(JSON.stringify(responseSchema)).not.toContain('inviteToken'); + expect(JSON.stringify(responseSchema)).not.toContain('inviteUrl'); + }); + it('documents bearer auth metadata for protected procedures', () => { const document = generateTrpcOpenApiDocument(); diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts index 66ddcc20b1..6194aaf19e 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.ts @@ -1,10 +1,17 @@ import * as z from 'zod'; import { + publicRestOpenApiRoutes, publicTrpcOpenApiProcedures, + type RestOpenApiRoute, type TrpcOpenApiProcedure, } from '@/lib/openapi/trpc-registry'; import { TrpcErrorResponseSchema, trpcSuccessResponseJsonSchema } from '@/lib/trpc/transport'; +const RestErrorResponseSchema = z.object({ + error: z.string(), + message: z.string().optional(), +}); + type JsonSchema = Record; type OpenApiDocument = { @@ -38,6 +45,10 @@ function successResponseSchema(data: JsonSchema): JsonSchema { return trpcSuccessResponseJsonSchema(data); } +function jsonResponseSchema(data: JsonSchema): JsonSchema { + return data; +} + function errorResponse(description: string) { return { description, @@ -82,6 +93,20 @@ function requestShapeForProcedure(procedure: TrpcOpenApiProcedure) { }; } +function pathParametersForRoute(route: RestOpenApiRoute) { + if (!route.pathParameters || route.pathParameters.length === 0) return {}; + + return { + parameters: route.pathParameters.map(parameter => ({ + name: parameter.name, + in: 'path', + required: true, + description: parameter.description, + schema: zodToJsonSchema(parameter.schema), + })), + }; +} + function operationForProcedure(procedure: TrpcOpenApiProcedure) { return { operationId: procedure.procedurePath.replaceAll('.', '_'), @@ -107,6 +132,59 @@ function operationForProcedure(procedure: TrpcOpenApiProcedure) { }; } +function operationForRoute(route: RestOpenApiRoute) { + return { + operationId: route.operationId, + tags: route.tags, + summary: route.summary, + description: route.description, + security: [{ bearerAuth: [] }], + ...pathParametersForRoute(route), + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: jsonResponseSchema(zodToJsonSchema(route.output)), + }, + }, + }, + '400': { + ...errorResponse('Invalid request'), + content: { + 'application/json': { + schema: zodToJsonSchema(RestErrorResponseSchema), + }, + }, + }, + '401': { + ...errorResponse('Authentication required'), + content: { + 'application/json': { + schema: zodToJsonSchema(RestErrorResponseSchema), + }, + }, + }, + '403': { + ...errorResponse('Access denied'), + content: { + 'application/json': { + schema: zodToJsonSchema(RestErrorResponseSchema), + }, + }, + }, + '500': { + ...errorResponse('Unexpected server error'), + content: { + 'application/json': { + schema: zodToJsonSchema(RestErrorResponseSchema), + }, + }, + }, + }, + }; +} + export function generateTrpcOpenApiDocument(): OpenApiDocument { const paths: OpenApiDocument['paths'] = {}; const tagNames = new Set(); @@ -120,10 +198,18 @@ export function generateTrpcOpenApiDocument(): OpenApiDocument { }; } + for (const route of publicRestOpenApiRoutes) { + for (const tag of route.tags) tagNames.add(tag); + paths[route.path] = { + ...paths[route.path], + [route.method]: operationForRoute(route), + }; + } + return { openapi: '3.1.0', info: { - title: 'Kilo Code tRPC API', + title: 'Kilo Code API', version: '1.0.0', }, servers: [{ url: '/' }], diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts index 0770b36ee7..0134fef778 100644 --- a/apps/web/src/lib/openapi/trpc-registry.ts +++ b/apps/web/src/lib/openapi/trpc-registry.ts @@ -1,5 +1,6 @@ import type { inferRouterInputs } from '@trpc/server'; -import type * as z from 'zod'; +import * as z from 'zod'; +import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types'; import type { usageAnalyticsRouter } from '@/routers/usage-analytics-router'; import { BreakdownInputSchema, @@ -22,6 +23,21 @@ export type TrpcOpenApiProcedure = { output: z.ZodType; }; +export type RestOpenApiRoute = { + path: string; + method: 'get' | 'post'; + operationId: string; + tags: string[]; + summary: string; + description?: string; + pathParameters?: Array<{ + name: string; + description: string; + schema: z.ZodType; + }>; + output: z.ZodType; +}; + type UsageAnalyticsProcedureKey = Extract< keyof inferRouterInputs, string @@ -79,3 +95,23 @@ export const publicTrpcOpenApiProcedures = [ output: TableOutputSchema, }), ]; + +export const publicRestOpenApiRoutes = [ + { + path: '/api/v1/organizations/{id}/members', + method: 'get', + operationId: 'organizations_getMembers', + tags: ['Organizations'], + summary: 'Return organization members', + description: + 'Returns active and invited members for an organization the authenticated user can access. Invite tokens and invite URLs are omitted from the response.', + pathParameters: [ + { + name: 'id', + description: 'Organization ID.', + schema: z.string(), + }, + ], + output: PublicOrganizationMembersSchema, + }, +] satisfies RestOpenApiRoute[];