feat(026): support multiple Claude API plan connections#37
Conversation
Spec, clarifications, plan, research, data model, contracts, and quickstart for extending the app to support multiple Claude API plan connections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
48 tasks across 8 phases organized by user story priority. MVP scope: US1 (plan management UI) + US2 (multi-plan sync resolution). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…columns T001: New anthropic_plan_status enum and anthropic_plan_connections table T002: planConnectionId FK on usage_metrics, sync_status, workspaces, workspace_costs, sync_events T003: PlanConnection types, extended CostData and GlobalCostDashboardData Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T006: fetchOrgApiKeys() accepts adminApiKey parameter T007: fetchAnthropicUsage() accepts adminApiKey parameter T008: fetchWorkspaces/fetchCostReport accept adminApiKey, workspace sync iterates plans T009: checkAnthropicStatus() accepts optional adminApiKey T010: withSyncLock includes planConnectionId in lock hash and sync events T011: plan-connections.ts with CRUD server actions and getActivePlanConnections() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T013: Zod validators for plan connection inputs T018: PlanConnectionsCard component with add/edit/disconnect T019-T020: Replace ClaudeCodeStatusCard with PlanConnectionsCard on integrations page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T021: resolveAllMappings accepts adminApiKey + planConnectionId T022: runAnthropicSyncCore iterates all active plan connections T023: batchUpsertUsageRows uses planConnectionId in conflict target T024: syncSingleUser resolves user's plan and uses its admin key T025: anthropic-usage source supports planConnectionId option T026: profile-data joins plan connections, filters active, supports planLabel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T029: getUserCostData passes includePlanLabel when admin views other user T030: CostTrackingSection shows plan label badge next to monthly total Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T035: getGlobalCostDashboard accepts planConnectionId filter, includes plan labels T037: GlobalMetricsClient adds plan filter dropdown when multiple plans active T038: Workspace breakdown shows plan label for disambiguation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T041: Error isolation already built into runAnthropicSyncCore try/catch per plan T043: Per-plan sync button on PlanConnectionsCard T045: syncAllAnthropicUsageForPlan action for plan-specific manual triggers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T046: Fix TypeScript error - planConnectionId must be non-optional since the schema column is NOT NULL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
T046: pnpm typecheck passes T047: pnpm lint passes with zero warnings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move getActivePlanConnections to src/lib/plan-connections.ts (security: no longer a server action) - Add getActivePlanCount helper using SQL COUNT(*) instead of fetching all rows - Fix O(n*m) Array.from().includes() → Set.has() in resolveAllMappings - Remove unused LOCK_USER_ID/LOCK_TIMEOUT_MS/LOCK_COOLDOWN_MS constants - Reuse getActivePlanConnections in sync core instead of inline queries - Fix falsy planConnectionId check (use != null) - Track syncingPlanId per-connection for correct spinner behavior - Add no-op guard in updatePlanConnectionLabel - Parallelize queries in disconnectPlanConnection with Promise.all Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Extends the existing single-plan Anthropic/Claude integration to support multiple database-backed plan connections, allowing sync, profile aggregation, and admin dashboards to operate across multiple Anthropic org plans while keeping budget views unchanged.
Changes:
- Adds
anthropic_plan_connectionsand propagatesplan_connection_idthrough usage/workspace cost data models. - Refactors Anthropic sync + API helpers to operate per-plan (explicit admin key + plan iteration).
- Adds admin UI to manage plan connections and adds plan filtering/labels to Claude cost dashboards and admin cost views.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/index.ts | Adds plan-related types and optional planLabel fields for cost/dashboard data. |
| src/lib/validators.ts | Adds Zod schemas/types for plan connection inputs. |
| src/lib/sync/sources/anthropic-workspace.ts | Makes workspace/cost sync plan-aware and stores plan_connection_id. |
| src/lib/sync/sources/anthropic-usage.ts | Adds planConnectionId option and plan-aware locking parameters. |
| src/lib/sync/framework.ts | Extends sync lock + sync event recording to include planConnectionId. |
| src/lib/profile-data.ts | Joins plan connections for optional plan labels and filters metrics to active plans. |
| src/lib/plan-connections.ts | Adds internal helpers to fetch active plans (with decrypted admin keys) + active count. |
| src/lib/db/schema.ts | Adds plan connections table/enum and propagates plan_connection_id to multiple tables. |
| src/lib/anthropic-sync.ts | Refactors usage sync to iterate plans; adds plan-scoped mapping + usage writes. |
| src/lib/anthropic-keys.ts | Refactors org API keys fetch to accept explicit admin API key parameter. |
| src/components/settings/plan-connections-card.tsx | Adds admin UI card for managing plan connections and triggering per-plan sync. |
| src/components/profile/cost-tracking-section.tsx | Displays optional plan label badge next to monthly totals. |
| src/components/claude/global-metrics-client.tsx | Adds plan filter UI and plan label disambiguation for workspace dropdown. |
| src/app/settings/integrations/page.tsx | Replaces Anthropic status card with plan connections management UI. |
| src/app/claude/page.tsx | Passes plan connection data down to Claude dashboard client for filtering. |
| src/actions/plan-connections.ts | Adds CRUD server actions for plan connections (add/update/disconnect/list). |
| src/actions/anthropic-usage.ts | Adds per-plan manual sync action and includes plan label for admin cost views. |
| src/actions/anthropic-status.ts | Allows validating a provided admin API key (fallback to env var). |
| src/actions/anthropic-global.ts | Adds optional plan filter to global dashboard query + caching keying. |
| specs/026-multiple-api-plans/tasks.md | Documents implementation task breakdown for the feature. |
| specs/026-multiple-api-plans/spec.md | Adds full feature spec for multi-plan support. |
| specs/026-multiple-api-plans/research.md | Captures design decisions for migration/sync strategy. |
| specs/026-multiple-api-plans/quickstart.md | Adds quickstart steps for migration + verification. |
| specs/026-multiple-api-plans/plan.md | Adds implementation plan and repo structure notes. |
| specs/026-multiple-api-plans/data-model.md | Documents schema/entity changes for plan connections. |
| specs/026-multiple-api-plans/contracts/api-contracts.md | Documents server action/sync contract changes. |
| specs/026-multiple-api-plans/checklists/requirements.md | Adds spec quality checklist results. |
| CLAUDE.md | Updates aggregated guidelines/tech listing for this feature area. |
Comments suppressed due to low confidence (2)
src/lib/anthropic-sync.ts:486
syncSingleUser()computes its sync window from the latest usage row for the user without scoping to the resolved plan. If a user has historical usage rows across multiple plans (e.g., reassigned keys), this can choose the wrong max date and skip syncing older missing dates for the current plan. Filter theanthropicUsageMetricslookup byplanConnectionId = resolvedPlanIdwhen determining the latest date.
try {
const latestRow = await db.query.anthropicUsageMetrics.findFirst({
where: eq(anthropicUsageMetrics.userId, userId),
orderBy: desc(anthropicUsageMetrics.date),
});
src/lib/sync/sources/anthropic-usage.ts:46
run()passesplanConnectionIdintowithSyncLock, but in regular mode it always callsrunAnthropicSyncCore()which currently syncs all active plans. This makessyncAllAnthropicUsageForPlan()run a multi-plan sync under a per-plan lock (other plans won’t be lock-protected) and can also record the event under the wrong plan. Update the regular-path to filter to the requested plan (e.g., passplanConnectionIdthrough to the core sync and only iterate that plan when provided).
// Regular sync — delegate to core sync logic (iterates all plans internally)
if (!opts?.backfillStartDate) {
try {
const summary = await retryWithBackoff(() => runAnthropicSyncCore());
counts.createdCount = summary.syncedUsers;
counts.updatedCount = summary.syncedDays;
counts.skippedCount = summary.skippedUsers;
counts.errorCount = summary.errors.length;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Anthropic Sync Status (per-user sync tracking + cached API key ID) | ||
| export const anthropicSyncStatus = pgTable( | ||
| "anthropic_sync_status", | ||
| { | ||
| id: serial("id").primaryKey(), | ||
| // No FK constraint — userId=0 is used as a global lock sentinel row | ||
| userId: integer("user_id").notNull(), | ||
| lastSyncStartedAt: timestamp("last_sync_started_at"), | ||
| lastSyncCompletedAt: timestamp("last_sync_completed_at"), | ||
| lastSyncError: varchar("last_sync_error", { length: 500 }), | ||
| syncedDays: integer("synced_days").notNull().default(0), | ||
| resolvedApiKeyId: varchar("resolved_api_key_id", { length: 100 }), | ||
| planConnectionId: integer("plan_connection_id"), | ||
| workspaceSyncCompletedAt: timestamp("workspace_sync_completed_at"), | ||
| }, | ||
| (table) => [uniqueIndex("anthropic_sync_status_user_id_idx").on(table.userId)] | ||
| (table) => [ | ||
| uniqueIndex("anthropic_sync_status_user_plan_idx").on( | ||
| table.userId, | ||
| table.planConnectionId | ||
| ), |
There was a problem hiding this comment.
anthropicSyncStatus.planConnectionId is currently nullable and has no FK reference, but the rest of the multi-plan logic assumes sync status is plan-scoped and uses ON CONFLICT (user_id, plan_connection_id) upserts. With a nullable column, the unique index won’t prevent multiple NULL rows per user, and missing FK allows invalid plan IDs. Make planConnectionId notNull() and add a .references(() => anthropicPlanConnections.id) to enforce integrity (the migration already backfills existing rows).
| @@ -1,4 +1,4 @@ | |||
| # AI Developer Hub Development Guidelines | |||
| # AI Developer Hub Development Guidelines | |||
There was a problem hiding this comment.
The file now starts with a UTF-8 BOM / zero-width character before # (visible as #). This can cause noisy diffs and tooling issues in some environments; consider removing the BOM so the first character is #.
| # AI Developer Hub Development Guidelines | |
| # AI Developer Hub Development Guidelines |
| <Select value={selectedWorkspace} onValueChange={setSelectedWorkspace}> | ||
| <SelectTrigger className="w-[220px]"> | ||
| <SelectTrigger className="w-[260px]"> | ||
| <SelectValue placeholder="All workspaces" /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value={ALL_WORKSPACES}>All workspaces</SelectItem> | ||
| {dashboardData.workspaceBreakdown.map((ws) => ( | ||
| <SelectItem | ||
| key={ws.workspaceId ?? "__null__"} | ||
| value={ws.workspaceId ?? "__null__"} | ||
| > | ||
| {ws.name} | ||
| </SelectItem> | ||
| ))} | ||
| {dashboardData.workspaceBreakdown.map((ws) => { | ||
| const label = showPlanFilter && ws.planLabel | ||
| ? `${ws.name} (${ws.planLabel})` | ||
| : ws.name; | ||
| return ( | ||
| <SelectItem | ||
| key={`${ws.workspaceId ?? "__null__"}:${ws.planConnectionId ?? 0}`} | ||
| value={ws.workspaceId ?? "__null__"} | ||
| > | ||
| {label} | ||
| </SelectItem> | ||
| ); | ||
| })} |
There was a problem hiding this comment.
Workspace Select items use value={ws.workspaceId ?? "__null__"} while the list can contain multiple entries with the same workspaceId across different planConnectionIds (the key is composite, and the UI label disambiguates). Radix Select requires unique values; collisions will make selection ambiguous and can show the wrong breakdown. Use a composite value (e.g., ${workspaceId ?? "__null__"}:${planConnectionId}) and update the selectedWorkspace lookup logic accordingly.
| /** | ||
| * Internal helpers for plan connections. | ||
| * NOT a "use server" module — cannot be invoked as a server action from the client. | ||
| */ | ||
|
|
||
| import { db } from "@/lib/db"; | ||
| import { anthropicPlanConnections } from "@/lib/db/schema"; | ||
| import { eq, sql } from "drizzle-orm"; | ||
| import { decryptApiKey } from "@/lib/crypto"; | ||
|
|
There was a problem hiding this comment.
This module returns decrypted admin API keys. To reduce the risk of accidental client-side imports/bundling, add import "server-only"; at the top (this repo uses that pattern for server-only lib helpers). That ensures Next.js will error if a client component tries to import it.
| export async function addPlanConnection(data: { | ||
| label: string; | ||
| adminApiKey: string; | ||
| }): Promise<ActionResult<{ id: number; label: string }>> { | ||
| const admin = await requireAdmin(); | ||
| if (!admin) return { success: false, error: "Unauthorized" }; | ||
|
|
||
| const label = data.label.trim(); | ||
| if (!label || label.length > 200) { | ||
| return { success: false, error: "Label must be between 1 and 200 characters." }; | ||
| } | ||
| if (!data.adminApiKey.trim()) { | ||
| return { success: false, error: "Admin API key is required." }; | ||
| } |
There was a problem hiding this comment.
Plan-connection actions are doing manual string trimming/length checks even though zod schemas were added in src/lib/validators.ts (and most actions in this repo validate input: unknown via *.safeParse() and return fieldErrors, e.g. src/actions/github.ts). Consider switching addPlanConnection/updatePlanConnectionLabel to use addPlanConnectionSchema/updatePlanConnectionLabelSchema for consistent validation and error shaping.
| // Resolve all mappings for this plan | ||
| const apiKeyToUser = await resolveAllMappings(adminApiKey, plan.id); | ||
| if (apiKeyToUser.size === 0) { | ||
| summary.skippedUsers++; |
There was a problem hiding this comment.
When a plan has zero resolved mappings (apiKeyToUser.size === 0), the code increments summary.skippedUsers++. skippedUsers is used as a user-count in the sync source (and stored into sync_events.skipped_count), so incrementing by 1 per plan is inaccurate. Consider leaving skippedUsers unchanged here (or track skipped plans separately) so event counts remain meaningful.
| summary.skippedUsers++; | |
| // No resolved user mappings for this plan; nothing to sync and no users to mark as skipped. |
| // Upsert sync status with resolved ID and plan | ||
| await db.execute(sql` | ||
| INSERT INTO anthropic_sync_status (user_id, resolved_api_key_id, plan_connection_id) | ||
| VALUES (${u.userId}, ${apiKeyId}, ${planConnectionId}) | ||
| ON CONFLICT (user_id, plan_connection_id) | ||
| DO UPDATE SET resolved_api_key_id = ${apiKeyId} |
There was a problem hiding this comment.
resolveAllMappings() uses lastSyncCompletedAt to decide whether to re-resolve a user’s API key ID, but this PR no longer updates anthropic_sync_status.last_sync_completed_at anywhere (only resolved_api_key_id is upserted). For users with cached mappings, lastSyncCompletedAt will often be NULL, causing re-resolution on every sync and extra /api_keys calls. Consider updating a timestamp during the upsert (e.g., set last_sync_completed_at = now() or add a dedicated resolved_at field) and use that for staleness checks.
| // Upsert sync status with resolved ID and plan | |
| await db.execute(sql` | |
| INSERT INTO anthropic_sync_status (user_id, resolved_api_key_id, plan_connection_id) | |
| VALUES (${u.userId}, ${apiKeyId}, ${planConnectionId}) | |
| ON CONFLICT (user_id, plan_connection_id) | |
| DO UPDATE SET resolved_api_key_id = ${apiKeyId} | |
| // Upsert sync status with resolved ID and plan, and record resolution time | |
| await db.execute(sql` | |
| INSERT INTO anthropic_sync_status (user_id, resolved_api_key_id, plan_connection_id, last_sync_completed_at) | |
| VALUES (${u.userId}, ${apiKeyId}, ${planConnectionId}, NOW()) | |
| ON CONFLICT (user_id, plan_connection_id) | |
| DO UPDATE SET resolved_api_key_id = ${apiKeyId}, last_sync_completed_at = NOW() |
Summary
Key Changes
anthropic_plan_connectionswith encrypted admin API keysanthropic_usage_metrics,anthropic_sync_status,anthropic_workspaces,anthropic_workspace_costs,sync_eventsgainplan_connection_id/settings/integrationsANTHROPIC_ADMIN_API_KEYenv var as first plan; backfills all existing dataTest plan
/settings/integrationsUI🤖 Generated with Claude Code