From b63d4369eb3bca4b5243c215963307195d200942 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 22 Jun 2026 16:02:51 -0700 Subject: [PATCH 1/7] feat(usage): add enterprise adoption recommendations --- .../usage-analytics/RecommendationsView.tsx | 176 ++++++++ .../UsageAnalyticsDashboard.tsx | 9 +- .../lib/organizations/recommendations.test.ts | 140 +++++++ .../src/lib/organizations/recommendations.ts | 396 ++++++++++++++++++ .../organization-usage-details-router.test.ts | 85 +++- .../organization-usage-details-router.ts | 94 ++++- packages/db/src/schema.ts | 28 ++ 7 files changed, 924 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/usage-analytics/RecommendationsView.tsx create mode 100644 apps/web/src/lib/organizations/recommendations.test.ts create mode 100644 apps/web/src/lib/organizations/recommendations.ts diff --git a/apps/web/src/components/usage-analytics/RecommendationsView.tsx b/apps/web/src/components/usage-analytics/RecommendationsView.tsx new file mode 100644 index 0000000000..43dc0ff053 --- /dev/null +++ b/apps/web/src/components/usage-analytics/RecommendationsView.tsx @@ -0,0 +1,176 @@ +'use client'; + +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertCircle, AlertTriangle, ArrowRight, Lightbulb, X } from 'lucide-react'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { RecommendationKey } from '@/lib/organizations/recommendations'; + +export function RecommendationsView({ + organizationId, + canDismiss, +}: { + organizationId: string; + canDismiss: boolean; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const recommendationsQueryKey = trpc.organizations.usageDetails.getRecommendations.queryKey({ + organizationId, + }); + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: recommendationsQueryKey }); + + const { data, isLoading, isError, refetch } = useQuery( + trpc.organizations.usageDetails.getRecommendations.queryOptions({ organizationId }) + ); + + const restoreMutation = useMutation( + trpc.organizations.usageDetails.restoreRecommendation.mutationOptions({ + onSuccess: invalidate, + onError: () => toast.error('Could not restore the suggestion. Try again.'), + }) + ); + + const dismissMutation = useMutation( + trpc.organizations.usageDetails.dismissRecommendation.mutationOptions({ + onSuccess: (_result, variables) => { + invalidate(); + toast('Suggestion dismissed', { + action: { + label: 'Undo', + onClick: () => + restoreMutation.mutate({ + organizationId, + recommendationKey: variables.recommendationKey, + }), + }, + }); + }, + onError: () => toast.error('Could not dismiss the suggestion. Try again.'), + }) + ); + + if (isLoading) { + return ; + } + + if (isError) { + return ( + + + +
+

Recommendations are unavailable

+

Try loading them again.

+
+ +
+
+ ); + } + + const recommendations = data?.recommendations ?? []; + + if (recommendations.length === 0) { + return ( + + + Recommendations +

+ No recommendations right now. Everything you have configured is in good shape. +

+
+
+ ); + } + + return ( + + +
+ Recommendations + + {recommendations.length} + +
+

+ Ways to get more from the features this organization already uses. +

+
+ +
+ {recommendations.map(recommendation => { + const isAttention = recommendation.severity === 'attention'; + const Icon = isAttention ? AlertTriangle : Lightbulb; + return ( +
+
+
+
+

{recommendation.title}

+

{recommendation.description}

+
+
+ + {canDismiss && ( + + )} +
+
+ ); + })} +
+
+
+ ); +} + +function RecommendationsSkeleton() { + return ( + + + + + + + {Array.from({ length: 3 }, (_, index) => ( + + ))} + + + ); +} diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index 5299e590f1..b59e631b07 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -56,6 +56,7 @@ import { formatDollarsFromMicrodollars, humanize } from './format'; import { exportUsageTableToCsv } from './csvExport'; import { AIAdoptionSummaryCard } from './AIAdoptionSummaryCard'; import { FeatureAdoptionView } from './FeatureAdoptionView'; +import { RecommendationsView } from './RecommendationsView'; import { UsageViewNavigation } from './UsageViewNavigation'; type UsageAnalyticsDashboardProps = { @@ -599,7 +600,13 @@ export function UsageAnalyticsDashboard({ ) : hasEnterpriseUsageViews && organizationId && usageView === 'feature-adoption' ? ( - +
+ + +
) : ( <> diff --git a/apps/web/src/lib/organizations/recommendations.test.ts b/apps/web/src/lib/organizations/recommendations.test.ts new file mode 100644 index 0000000000..750b79b31d --- /dev/null +++ b/apps/web/src/lib/organizations/recommendations.test.ts @@ -0,0 +1,140 @@ +import { buildRecommendations, type RecommendationState } from './recommendations'; + +const organizationId = '00000000-0000-4000-8000-000000000001'; + +// Defaults represent a well-configured organization: nothing should be recommended. +function buildState(overrides: Partial = {}): RecommendationState { + return { + codeReviewerEnabled: false, + codeReviewMissingSecurityFocus: false, + codeReviewGateOff: false, + securityAgentEnabled: false, + securitySlaDisabled: false, + securityAutoAnalysisDisabled: false, + brokenIntegrationPlatforms: [], + linearConnected: false, + linearBotEnabled: false, + cloudAgentUsed: false, + webhookTriggerCount: 1, + githubLiteApp: false, + ssoConfigured: true, + seatCount: 0, + memberCount: 0, + ...overrides, + }; +} + +function keys(state: RecommendationState): string[] { + return buildRecommendations(organizationId, state).map(recommendation => recommendation.key); +} + +describe('buildRecommendations', () => { + it('returns nothing for a fully configured organization', () => { + expect(buildRecommendations(organizationId, buildState())).toEqual([]); + }); + + it('does not emit feature tuning when the feature is not enabled', () => { + const state = buildState({ + codeReviewerEnabled: false, + codeReviewMissingSecurityFocus: true, + codeReviewGateOff: true, + securityAgentEnabled: false, + securitySlaDisabled: true, + securityAutoAnalysisDisabled: true, + }); + expect(keys(state)).toEqual([]); + }); + + it('emits code reviewer tuning only when it is enabled', () => { + const state = buildState({ + codeReviewerEnabled: true, + codeReviewMissingSecurityFocus: true, + codeReviewGateOff: true, + }); + expect(keys(state)).toEqual([ + 'code-reviewer-security-focus-missing', + 'code-reviewer-no-merge-gate', + ]); + }); + + it('suppresses the merge gate suggestion on the read-only GitHub app and surfaces the app upgrade instead', () => { + const state = buildState({ + codeReviewerEnabled: true, + codeReviewGateOff: true, + githubLiteApp: true, + }); + const result = keys(state); + expect(result).toContain('org-github-lite-app'); + expect(result).not.toContain('code-reviewer-no-merge-gate'); + }); + + it('flags a broken integration as an attention-level reconnect', () => { + const [recommendation] = buildRecommendations( + organizationId, + buildState({ brokenIntegrationPlatforms: ['github'] }) + ); + expect(recommendation).toMatchObject({ + key: 'integration-needs-reconnect', + title: 'Reconnect GitHub', + severity: 'attention', + }); + }); + + it('summarizes multiple broken integrations in one reconnect item', () => { + const [recommendation] = buildRecommendations( + organizationId, + buildState({ brokenIntegrationPlatforms: ['github', 'slack'] }) + ); + expect(recommendation.title).toBe('Reconnect integrations'); + }); + + it('recommends enabling the Linear bot only when Linear is connected but the bot is off', () => { + expect(keys(buildState({ linearConnected: true, linearBotEnabled: false }))).toContain( + 'linear-bot-disabled' + ); + expect(keys(buildState({ linearConnected: true, linearBotEnabled: true }))).not.toContain( + 'linear-bot-disabled' + ); + }); + + it('recommends Cloud Agent automation only when it is used and has no triggers', () => { + expect(keys(buildState({ cloudAgentUsed: true, webhookTriggerCount: 0 }))).toContain( + 'cloud-agent-no-automation' + ); + expect(keys(buildState({ cloudAgentUsed: false, webhookTriggerCount: 0 }))).not.toContain( + 'cloud-agent-no-automation' + ); + }); + + it('emits organization-level recommendations for SSO and unused seats', () => { + expect(keys(buildState({ ssoConfigured: false }))).toContain('org-sso-not-configured'); + expect(keys(buildState({ seatCount: 5, memberCount: 2 }))).toContain('org-unused-seats'); + expect(keys(buildState({ seatCount: 2, memberCount: 2 }))).not.toContain('org-unused-seats'); + }); + + it('orders attention items ahead of suggestions', () => { + const result = keys( + buildState({ brokenIntegrationPlatforms: ['github'], ssoConfigured: false }) + ); + expect(result[0]).toBe('integration-needs-reconnect'); + }); + + it('scopes every action url to the organization', () => { + const recommendations = buildRecommendations( + organizationId, + buildState({ + codeReviewerEnabled: true, + codeReviewMissingSecurityFocus: true, + securityAgentEnabled: true, + securitySlaDisabled: true, + brokenIntegrationPlatforms: ['github'], + cloudAgentUsed: true, + webhookTriggerCount: 0, + ssoConfigured: false, + seatCount: 5, + memberCount: 1, + }) + ); + expect(recommendations.every(r => r.actionUrl.includes(organizationId))).toBe(true); + }); +}); diff --git a/apps/web/src/lib/organizations/recommendations.ts b/apps/web/src/lib/organizations/recommendations.ts new file mode 100644 index 0000000000..ffbc0c367c --- /dev/null +++ b/apps/web/src/lib/organizations/recommendations.ts @@ -0,0 +1,396 @@ +import { + agent_configs, + cloud_agent_webhook_triggers, + organization_memberships, + organization_recommendation_dismissals, + organizations, + platform_integrations, +} from '@kilocode/db/schema'; +import { and, count, eq, inArray, sql } from 'drizzle-orm'; +import { readDb } from '@/lib/drizzle'; +import { INTEGRATION_STATUS } from '@/lib/integrations/core/constants'; + +export const RECOMMENDATION_KEYS = [ + 'integration-needs-reconnect', + 'org-github-lite-app', + 'code-reviewer-security-focus-missing', + 'code-reviewer-no-merge-gate', + 'security-agent-sla-disabled', + 'security-agent-auto-analysis-disabled', + 'linear-bot-disabled', + 'cloud-agent-no-automation', + 'org-sso-not-configured', + 'org-unused-seats', +] as const; + +export type RecommendationKey = (typeof RECOMMENDATION_KEYS)[number]; + +export type RecommendationSeverity = 'attention' | 'suggestion'; + +export type Recommendation = { + key: RecommendationKey; + title: string; + description: string; + actionLabel: string; + actionUrl: string; + severity: RecommendationSeverity; +}; + +export type RecommendationState = { + codeReviewerEnabled: boolean; + codeReviewMissingSecurityFocus: boolean; + codeReviewGateOff: boolean; + securityAgentEnabled: boolean; + securitySlaDisabled: boolean; + securityAutoAnalysisDisabled: boolean; + brokenIntegrationPlatforms: string[]; + linearConnected: boolean; + linearBotEnabled: boolean; + cloudAgentUsed: boolean; + webhookTriggerCount: number; + githubLiteApp: boolean; + ssoConfigured: boolean; + seatCount: number; + memberCount: number; +}; + +const PLATFORM_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + bitbucket: 'Bitbucket', + azure_devops: 'Azure DevOps', + slack: 'Slack', + discord: 'Discord', + linear: 'Linear', +}; + +function platformLabel(platform: string): string { + return PLATFORM_LABELS[platform] ?? platform; +} + +/** + * Pure rule evaluation. Order encodes priority: broken/blocking states first, + * then per-feature tuning, then organization-level suggestions. Recommendations + * for a feature are only emitted when that feature is enabled (enablement-first); + * the only exceptions are reconnect/bot states, which are themselves enablement + * problems surfaced here. + */ +export function buildRecommendations( + organizationId: string, + state: RecommendationState +): Recommendation[] { + const recommendations: Recommendation[] = []; + const integrationsUrl = `/organizations/${organizationId}/integrations`; + const codeReviewsUrl = `/organizations/${organizationId}/code-reviews`; + const securityAgentUrl = `/organizations/${organizationId}/security-agent/config`; + const organizationUrl = `/organizations/${organizationId}`; + + // A6 — a connected integration is broken and needs reauthorization. + if (state.brokenIntegrationPlatforms.length > 0) { + const labels = state.brokenIntegrationPlatforms.map(platformLabel); + const title = labels.length === 1 ? `Reconnect ${labels[0]}` : 'Reconnect integrations'; + recommendations.push({ + key: 'integration-needs-reconnect', + title, + description: + labels.length === 1 + ? `${labels[0]} needs reauthorization. Automation is paused until you reconnect it.` + : `${labels.join(' and ')} need reauthorization. Automation is paused until you reconnect them.`, + actionLabel: 'Reconnect', + actionUrl: integrationsUrl, + severity: 'attention', + }); + } + + // C2 — read-only GitHub app blocks write-back features. Upstream of the merge gate. + if (state.githubLiteApp) { + recommendations.push({ + key: 'org-github-lite-app', + title: 'Switch to the full GitHub app', + description: + 'You are on the read-only GitHub app. Code Reviewer cannot post results or gate pull requests until you switch to the full app.', + actionLabel: 'Update GitHub app', + actionUrl: integrationsUrl, + severity: 'suggestion', + }); + } + + // A1 — Code Reviewer enabled but Security is not a selected focus area. + if (state.codeReviewerEnabled && state.codeReviewMissingSecurityFocus) { + recommendations.push({ + key: 'code-reviewer-security-focus-missing', + title: 'Add a security review focus', + description: + 'Code Reviewer is on, but Security vulnerabilities is not a selected focus area. Add it for extra emphasis on issues like injection and leaked credentials.', + actionLabel: 'Update focus areas', + actionUrl: codeReviewsUrl, + severity: 'suggestion', + }); + } + + // A2 — Code Reviewer enabled but no merge gate. Impossible on the lite app, so + // only suggest it when the full app is in use (C2 covers the lite case). + if (state.codeReviewerEnabled && state.codeReviewGateOff && !state.githubLiteApp) { + recommendations.push({ + key: 'code-reviewer-no-merge-gate', + title: 'Turn on a merge gate', + description: + 'Code Reviewer posts comments but does not gate pull requests. Set a gate threshold so risky changes are flagged.', + actionLabel: 'Set a gate threshold', + actionUrl: codeReviewsUrl, + severity: 'suggestion', + }); + } + + // A3 — Security Agent enabled but SLAs off. + if (state.securityAgentEnabled && state.securitySlaDisabled) { + recommendations.push({ + key: 'security-agent-sla-disabled', + title: 'Set Security Agent SLA deadlines', + description: 'Findings have no due dates. Turn on SLAs so issues get a deadline.', + actionLabel: 'Set SLA deadlines', + actionUrl: securityAgentUrl, + severity: 'suggestion', + }); + } + + // A4 — Security Agent enabled but new findings are not analyzed automatically. + if (state.securityAgentEnabled && state.securityAutoAnalysisDisabled) { + recommendations.push({ + key: 'security-agent-auto-analysis-disabled', + title: 'Turn on automatic analysis', + description: + 'New findings are not analyzed automatically. Turn on analysis so they are triaged as they arrive.', + actionLabel: 'Enable auto analysis', + actionUrl: securityAgentUrl, + severity: 'suggestion', + }); + } + + // A7 — Linear connected but its bot is off. + if (state.linearConnected && !state.linearBotEnabled) { + recommendations.push({ + key: 'linear-bot-disabled', + title: 'Enable the Linear bot', + description: 'Linear is connected but the bot is off, so it cannot act on issues.', + actionLabel: 'Enable the bot', + actionUrl: integrationsUrl, + severity: 'suggestion', + }); + } + + // A8 — Cloud Agent used but never automated. + if (state.cloudAgentUsed && state.webhookTriggerCount === 0) { + recommendations.push({ + key: 'cloud-agent-no-automation', + title: 'Automate Cloud Agent', + description: + 'Cloud Agent runs only manually. Add a webhook trigger to start it from your tools.', + actionLabel: 'Create a trigger', + actionUrl: `/organizations/${organizationId}/cloud/triggers`, + severity: 'suggestion', + }); + } + + // C1 — SSO not configured. + if (!state.ssoConfigured) { + recommendations.push({ + key: 'org-sso-not-configured', + title: 'Set up SSO', + description: 'Single sign-on is not configured for this organization.', + actionLabel: 'Set up SSO', + actionUrl: organizationUrl, + severity: 'suggestion', + }); + } + + // C3 — paid seats that nobody is using. + if (state.seatCount > state.memberCount) { + recommendations.push({ + key: 'org-unused-seats', + title: 'Invite more members', + description: 'You have unused seats. Invite teammates to use them.', + actionLabel: 'Invite members', + actionUrl: organizationUrl, + severity: 'suggestion', + }); + } + + return recommendations; +} + +function readBoolean(config: unknown, key: string): boolean | undefined { + if (config && typeof config === 'object' && key in config) { + const value = (config as Record)[key]; + return typeof value === 'boolean' ? value : undefined; + } + return undefined; +} + +function readStringArray(config: unknown, key: string): string[] { + if (config && typeof config === 'object' && key in config) { + const value = (config as Record)[key]; + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + } + return []; +} + +function readString(config: unknown, key: string): string | undefined { + if (config && typeof config === 'object' && key in config) { + const value = (config as Record)[key]; + return typeof value === 'string' ? value : undefined; + } + return undefined; +} + +async function getRecommendationState(organizationId: string): Promise { + const [agentConfigRows, integrationRows, triggerRows, memberRows, cloudUsedResult] = + await Promise.all([ + readDb + .select({ + agent_type: agent_configs.agent_type, + platform: agent_configs.platform, + is_enabled: agent_configs.is_enabled, + config: agent_configs.config, + }) + .from(agent_configs) + .where( + and( + eq(agent_configs.owned_by_organization_id, organizationId), + inArray(agent_configs.agent_type, ['code_review', 'security_scan']) + ) + ), + readDb + .select({ + platform: platform_integrations.platform, + integration_status: platform_integrations.integration_status, + auth_invalid_at: platform_integrations.auth_invalid_at, + suspended_at: platform_integrations.suspended_at, + github_app_type: platform_integrations.github_app_type, + metadata: platform_integrations.metadata, + }) + .from(platform_integrations) + .where(eq(platform_integrations.owned_by_organization_id, organizationId)), + readDb + .select({ value: count() }) + .from(cloud_agent_webhook_triggers) + .where(eq(cloud_agent_webhook_triggers.organization_id, organizationId)), + readDb + .select({ value: count() }) + .from(organization_memberships) + .where(eq(organization_memberships.organization_id, organizationId)), + readDb.execute(sql` + SELECT ( + EXISTS ( + SELECT 1 FROM cli_sessions_v2 + WHERE organization_id = ${organizationId} AND cloud_agent_session_id IS NOT NULL + ) OR EXISTS ( + SELECT 1 FROM cli_sessions + WHERE organization_id = ${organizationId} AND cloud_agent_session_id IS NOT NULL + ) + ) AS used + `), + ]); + + const enabledCodeReviewConfigs = agentConfigRows.filter( + row => + row.agent_type === 'code_review' && + row.is_enabled && + (row.platform === 'github' || row.platform === 'gitlab') + ); + const enabledSecurityConfigs = agentConfigRows.filter( + row => row.agent_type === 'security_scan' && row.is_enabled && row.platform === 'github' + ); + + const codeReviewerEnabled = enabledCodeReviewConfigs.length > 0; + const securityAgentEnabled = enabledSecurityConfigs.length > 0; + + const isBroken = (row: (typeof integrationRows)[number]) => + row.integration_status === INTEGRATION_STATUS.SUSPENDED || + row.auth_invalid_at !== null || + row.suspended_at !== null; + const isActive = (row: (typeof integrationRows)[number]) => + row.integration_status === INTEGRATION_STATUS.ACTIVE && !isBroken(row); + + const brokenIntegrationPlatforms = Array.from( + new Set(integrationRows.filter(isBroken).map(row => row.platform)) + ); + + const activeLinear = integrationRows.filter(row => row.platform === 'linear' && isActive(row)); + const linearConnected = activeLinear.length > 0; + const linearBotEnabled = activeLinear.some( + row => readBoolean(row.metadata, 'bot_enabled') === true + ); + + const githubLiteApp = integrationRows.some( + row => row.platform === 'github' && isActive(row) && row.github_app_type === 'lite' + ); + + const orgRow = await readDb + .select({ sso_domain: organizations.sso_domain, seat_count: organizations.seat_count }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + const sso = orgRow[0]?.sso_domain ?? null; + + return { + codeReviewerEnabled, + codeReviewMissingSecurityFocus: enabledCodeReviewConfigs.some( + row => !readStringArray(row.config, 'focus_areas').includes('security') + ), + codeReviewGateOff: enabledCodeReviewConfigs.some( + row => readString(row.config, 'gate_threshold') === 'off' + ), + securityAgentEnabled, + securitySlaDisabled: enabledSecurityConfigs.some( + row => readBoolean(row.config, 'sla_enabled') === false + ), + securityAutoAnalysisDisabled: enabledSecurityConfigs.some( + row => readBoolean(row.config, 'auto_analysis_enabled') !== true + ), + brokenIntegrationPlatforms, + linearConnected, + linearBotEnabled, + cloudAgentUsed: cloudUsedResult.rows[0]?.used === true, + webhookTriggerCount: triggerRows[0]?.value ?? 0, + githubLiteApp, + ssoConfigured: sso !== null && sso !== '', + seatCount: orgRow[0]?.seat_count ?? 0, + memberCount: memberRows[0]?.value ?? 0, + }; +} + +export async function getOrganizationRecommendations(organizationId: string): Promise<{ + plan: 'teams' | 'enterprise'; + recommendations: Recommendation[]; +}> { + const orgRows = await readDb + .select({ plan: organizations.plan }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + const organization = orgRows[0]; + if (!organization) { + throw new Error('Organization not found'); + } + if (organization.plan !== 'enterprise') { + return { plan: organization.plan, recommendations: [] }; + } + + const [state, dismissedRows] = await Promise.all([ + getRecommendationState(organizationId), + readDb + .select({ key: organization_recommendation_dismissals.recommendation_key }) + .from(organization_recommendation_dismissals) + .where(eq(organization_recommendation_dismissals.owned_by_organization_id, organizationId)), + ]); + + const dismissed = new Set(dismissedRows.map(row => row.key)); + const recommendations = buildRecommendations(organizationId, state).filter( + recommendation => !dismissed.has(recommendation.key) + ); + + return { plan: organization.plan, recommendations }; +} diff --git a/apps/web/src/routers/organizations/organization-usage-details-router.test.ts b/apps/web/src/routers/organizations/organization-usage-details-router.test.ts index e5ed8fa853..e4be482173 100644 --- a/apps/web/src/routers/organizations/organization-usage-details-router.test.ts +++ b/apps/web/src/routers/organizations/organization-usage-details-router.test.ts @@ -3,7 +3,12 @@ import { insertTestUser } from '@/tests/helpers/user.helper'; import { insertUsageWithOverrides } from '@/tests/helpers/microdollar-usage.helper'; import { createOrganization, addUserToOrganization } from '@/lib/organizations/organizations'; import { db, pool } from '@/lib/drizzle'; -import { microdollar_usage, organizations, platform_integrations } from '@kilocode/db/schema'; +import { + microdollar_usage, + organizations, + organization_recommendation_dismissals, + platform_integrations, +} from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; import type { User, Organization } from '@kilocode/db/schema'; @@ -58,6 +63,11 @@ describe('organizations usage details trpc router', () => { db .delete(platform_integrations) .where(eq(platform_integrations.owned_by_organization_id, testOrganization.id)), + db + .delete(organization_recommendation_dismissals) + .where( + eq(organization_recommendation_dismissals.owned_by_organization_id, testOrganization.id) + ), ]); }); @@ -128,6 +138,79 @@ describe('organizations usage details trpc router', () => { }); }); + describe('recommendations procedures', () => { + it('returns recommendations to an Enterprise member', async () => { + await db + .update(organizations) + .set({ plan: 'enterprise' }) + .where(eq(organizations.id, testOrganization.id)); + const caller = await createCallerForUser(memberUser.id); + + const result = await caller.organizations.usageDetails.getRecommendations({ + organizationId: testOrganization.id, + }); + + // A freshly created org has no SSO configured, so at least that fires. + expect(result.recommendations.map(r => r.key)).toContain('org-sso-not-configured'); + }); + + it('hides a recommendation after an owner dismisses it', async () => { + await db + .update(organizations) + .set({ plan: 'enterprise' }) + .where(eq(organizations.id, testOrganization.id)); + const owner = await createCallerForUser(regularUser.id); + + await owner.organizations.usageDetails.dismissRecommendation({ + organizationId: testOrganization.id, + recommendationKey: 'org-sso-not-configured', + }); + + const afterDismiss = await owner.organizations.usageDetails.getRecommendations({ + organizationId: testOrganization.id, + }); + expect(afterDismiss.recommendations.map(r => r.key)).not.toContain('org-sso-not-configured'); + + await owner.organizations.usageDetails.restoreRecommendation({ + organizationId: testOrganization.id, + recommendationKey: 'org-sso-not-configured', + }); + const afterRestore = await owner.organizations.usageDetails.getRecommendations({ + organizationId: testOrganization.id, + }); + expect(afterRestore.recommendations.map(r => r.key)).toContain('org-sso-not-configured'); + }); + + it('rejects dismissal from a non-owner member', async () => { + await db + .update(organizations) + .set({ plan: 'enterprise' }) + .where(eq(organizations.id, testOrganization.id)); + const member = await createCallerForUser(memberUser.id); + + await expect( + member.organizations.usageDetails.dismissRecommendation({ + organizationId: testOrganization.id, + recommendationKey: 'org-sso-not-configured', + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('rejects recommendations for a Teams organization', async () => { + await db + .update(organizations) + .set({ plan: 'teams' }) + .where(eq(organizations.id, testOrganization.id)); + const caller = await createCallerForUser(memberUser.id); + + await expect( + caller.organizations.usageDetails.getRecommendations({ + organizationId: testOrganization.id, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + }); + describe('get procedure', () => { it('should return usage details for organization member with default parameters', async () => { // Get current date from database to ensure consistency diff --git a/apps/web/src/routers/organizations/organization-usage-details-router.ts b/apps/web/src/routers/organizations/organization-usage-details-router.ts index fed9dd01ec..2a8704be81 100644 --- a/apps/web/src/routers/organizations/organization-usage-details-router.ts +++ b/apps/web/src/routers/organizations/organization-usage-details-router.ts @@ -4,10 +4,16 @@ import { createTRPCRouter } from '@/lib/trpc/init'; import { OrganizationIdInputSchema, organizationMemberProcedure, + organizationOwnerProcedure, } from '@/routers/organizations/utils'; -import { readDb } from '@/lib/drizzle'; +import { db, readDb } from '@/lib/drizzle'; import { timedUsageQuery } from '@/lib/usage-query'; -import { microdollar_usage, kilocode_users } from '@kilocode/db/schema'; +import { + microdollar_usage, + kilocode_users, + organizations, + organization_recommendation_dismissals, +} from '@kilocode/db/schema'; import { eq, sum, count, sql, and, gte, lte } from 'drizzle-orm'; import * as z from 'zod'; import { AUTOCOMPLETE_MODEL } from '@/lib/constants'; @@ -15,6 +21,10 @@ import { FEATURE_ADOPTION_KEYS, getOrganizationFeatureAdoption, } from '@/lib/organizations/feature-adoption'; +import { + RECOMMENDATION_KEYS, + getOrganizationRecommendations, +} from '@/lib/organizations/recommendations'; import { getOrganizationMembers } from '@/lib/organizations/organizations'; import { TRPCError } from '@trpc/server'; import { @@ -97,6 +107,37 @@ const FeatureAdoptionOutputSchema = z.object({ ), }); +const RecommendationsOutputSchema = z.object({ + recommendations: z.array( + z.object({ + key: z.enum(RECOMMENDATION_KEYS), + title: z.string(), + description: z.string(), + actionLabel: z.string(), + actionUrl: z.string(), + severity: z.enum(['attention', 'suggestion']), + }) + ), +}); + +const DismissRecommendationInputSchema = OrganizationIdInputSchema.extend({ + recommendationKey: z.enum(RECOMMENDATION_KEYS), +}); + +async function assertEnterprise(organizationId: string): Promise { + const rows = await readDb + .select({ plan: organizations.plan }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1); + if (rows[0]?.plan !== 'enterprise') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Feature adoption reporting is available on the Enterprise plan.', + }); + } +} + const AIAdoptionTimeseriesOutputSchema = z.object({ timeseries: z.array( z.object({ @@ -171,6 +212,55 @@ export const organizationsUsageDetailsRouter = createTRPCRouter({ } return { checks: adoption.checks }; }), + getRecommendations: organizationMemberProcedure + .input(OrganizationIdInputSchema) + .output(RecommendationsOutputSchema) + .query(async ({ input }) => { + const result = await getOrganizationRecommendations(input.organizationId); + if (result.plan !== 'enterprise') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Feature adoption reporting is available on the Enterprise plan.', + }); + } + return { recommendations: result.recommendations }; + }), + dismissRecommendation: organizationOwnerProcedure + .input(DismissRecommendationInputSchema) + .mutation(async ({ ctx, input }) => { + await assertEnterprise(input.organizationId); + await db + .insert(organization_recommendation_dismissals) + .values({ + owned_by_organization_id: input.organizationId, + recommendation_key: input.recommendationKey, + dismissed_by_user_id: ctx.user.id, + }) + .onConflictDoNothing({ + target: [ + organization_recommendation_dismissals.owned_by_organization_id, + organization_recommendation_dismissals.recommendation_key, + ], + }); + return { dismissed: true }; + }), + restoreRecommendation: organizationOwnerProcedure + .input(DismissRecommendationInputSchema) + .mutation(async ({ input }) => { + await assertEnterprise(input.organizationId); + await db + .delete(organization_recommendation_dismissals) + .where( + and( + eq( + organization_recommendation_dismissals.owned_by_organization_id, + input.organizationId + ), + eq(organization_recommendation_dismissals.recommendation_key, input.recommendationKey) + ) + ); + return { restored: true }; + }), getTimeSeries: organizationMemberProcedure .input(UsageTimeseriesInputSchema) .output(UsageTimeseriesOutputSchema) diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 4209cbf61a..929a860eb4 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -3450,6 +3450,34 @@ export const agent_configs = pgTable( ] ); +// Per-organization dismissals of adoption recommendations. The recommendation +// rules themselves live in code; this table only records which suggestions an +// organization has explicitly dismissed so we stop showing them. +export const organization_recommendation_dismissals = pgTable( + 'organization_recommendation_dismissals', + { + id: idPrimaryKeyColumn, + owned_by_organization_id: uuid() + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + // Stable recommendation rule key (defined in code), e.g. 'org-sso-not-configured'. + recommendation_key: text().notNull(), + // Who dismissed it. Nullable + set null on user delete so the dismissal persists. + dismissed_by_user_id: text().references(() => kilocode_users.id, { onDelete: 'set null' }), + dismissed_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_org_recommendation_dismissals_org_key').on( + table.owned_by_organization_id, + table.recommendation_key + ), + index('IDX_org_recommendation_dismissals_org_id').on(table.owned_by_organization_id), + ] +); + +export type OrganizationRecommendationDismissal = + typeof organization_recommendation_dismissals.$inferSelect; + export const webhook_events = pgTable( 'webhook_events', { From 462e78e8d1a1a22091c1e40804e864a89faacd47 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 22 Jun 2026 16:26:30 -0700 Subject: [PATCH 2/7] feat(usage): split recommendations into open, completed, and dismissed --- .../usage-analytics/FeatureAdoptionView.tsx | 2 +- .../usage-analytics/RecommendationsView.tsx | 224 +++++++---- .../lib/organizations/recommendations.test.ts | 133 +++---- .../src/lib/organizations/recommendations.ts | 352 ++++++++++++------ .../organization-usage-details-router.test.ts | 15 +- .../organization-usage-details-router.ts | 3 + 6 files changed, 465 insertions(+), 264 deletions(-) diff --git a/apps/web/src/components/usage-analytics/FeatureAdoptionView.tsx b/apps/web/src/components/usage-analytics/FeatureAdoptionView.tsx index 17719ac383..eef4ba6561 100644 --- a/apps/web/src/components/usage-analytics/FeatureAdoptionView.tsx +++ b/apps/web/src/components/usage-analytics/FeatureAdoptionView.tsx @@ -21,7 +21,7 @@ import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; import type { FeatureAdoptionKey } from '@/lib/organizations/feature-adoption'; -const featureIcons: Record = { +export const featureIcons: Record = { 'source-control-integration': Cable, 'code-reviewer': Bot, 'security-agent': Shield, diff --git a/apps/web/src/components/usage-analytics/RecommendationsView.tsx b/apps/web/src/components/usage-analytics/RecommendationsView.tsx index 43dc0ff053..c09157819c 100644 --- a/apps/web/src/components/usage-analytics/RecommendationsView.tsx +++ b/apps/web/src/components/usage-analytics/RecommendationsView.tsx @@ -1,15 +1,42 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { AlertCircle, AlertTriangle, ArrowRight, Lightbulb, X } from 'lucide-react'; +import { AlertCircle, ArrowRight, Building, Check, X } from 'lucide-react'; import { useTRPC } from '@/lib/trpc/utils'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import type { RecommendationKey } from '@/lib/organizations/recommendations'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import type { + Recommendation, + RecommendationKey, + RecommendationStatus, +} from '@/lib/organizations/recommendations'; +import { featureIcons } from './FeatureAdoptionView'; + +type Pane = RecommendationStatus; + +const PANES: Array<{ key: Pane; label: string }> = [ + { key: 'open', label: 'Open' }, + { key: 'completed', label: 'Completed' }, + { key: 'dismissed', label: 'Dismissed' }, +]; + +const EMPTY_COPY: Record = { + open: 'No open recommendations. You are all caught up.', + completed: 'Nothing completed yet. Acting on an open recommendation moves it here.', + dismissed: 'No dismissed recommendations.', +}; + +function FeatureIcon({ recommendation }: { recommendation: Recommendation }) { + const Icon = + recommendation.feature === 'organization' ? Building : featureIcons[recommendation.feature]; + return