Skip to content

feat(026): support multiple Claude API plan connections#37

Open
studert wants to merge 12 commits into
mainfrom
026-multiple-api-plans
Open

feat(026): support multiple Claude API plan connections#37
studert wants to merge 12 commits into
mainfrom
026-multiple-api-plans

Conversation

@studert
Copy link
Copy Markdown
Member

@studert studert commented Mar 27, 2026

Summary

  • Extends the app from supporting a single Claude API plan connection (env var) to multiple database-backed plan connections
  • Each plan stores its own encrypted admin API key and human-readable label
  • Sync framework iterates all active plans, resolving user API keys per-plan
  • Admin dashboard gains plan filter for workspace cost aggregation
  • Profile pages display usage transparently across plans; admin views show plan labels
  • Budget views remain completely unaffected (FR-011/SC-006)

Key Changes

  • New table: anthropic_plan_connections with encrypted admin API keys
  • Modified tables: anthropic_usage_metrics, anthropic_sync_status, anthropic_workspaces, anthropic_workspace_costs, sync_events gain plan_connection_id
  • New UI: Plan connections management card on /settings/integrations
  • Refactored sync: All Anthropic API functions accept explicit admin key parameter; sync core iterates plans with per-plan error isolation
  • Dashboard: Plan filter dropdown, workspace labels include plan name for disambiguation
  • Migration: Auto-imports existing ANTHROPIC_ADMIN_API_KEY env var as first plan; backfills all existing data

Test plan

  • Verify migration creates plan_connections table and auto-imports env var key
  • Add a second plan connection via /settings/integrations UI
  • Trigger sync and verify both plans sync with independent error isolation
  • Check user profile shows correct aggregated usage across plans
  • Check admin user detail page shows plan label badge
  • Verify Claude dashboard plan filter works correctly
  • Confirm budget views are unaffected
  • Disconnect a plan and verify historical data preserved

🤖 Generated with Claude Code

studert and others added 12 commits March 27, 2026 15:20
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ai-developer-hub Ready Ready Preview, Comment Mar 27, 2026 2:56pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_connections and propagates plan_connection_id through 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 the anthropicUsageMetrics lookup by planConnectionId = resolvedPlanId when 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() passes planConnectionId into withSyncLock, but in regular mode it always calls runAnthropicSyncCore() which currently syncs all active plans. This makes syncAllAnthropicUsageForPlan() 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., pass planConnectionId through 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.

Comment thread src/lib/db/schema.ts
Comment on lines 575 to +594
// 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
),
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread CLAUDE.md
@@ -1,4 +1,4 @@
# AI Developer Hub Development Guidelines
# AI Developer Hub Development Guidelines
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #.

Suggested change
# AI Developer Hub Development Guidelines
# AI Developer Hub Development Guidelines

Copilot uses AI. Check for mistakes.
Comment on lines 131 to +149
<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>
);
})}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +10
/**
* 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";

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +55
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." };
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/anthropic-sync.ts
// Resolve all mappings for this plan
const apiKeyToUser = await resolveAllMappings(adminApiKey, plan.id);
if (apiKeyToUser.size === 0) {
summary.skippedUsers++;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
summary.skippedUsers++;
// No resolved user mappings for this plan; nothing to sync and no users to mark as skipped.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/anthropic-sync.ts
Comment on lines +201 to +206
// 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}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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()

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants