From bc6a8ed5fa914b5cb559684056974fedcacd8b58 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 3 Jan 2026 15:31:10 -0500 Subject: [PATCH] Fix N+1 API calls in admin prospects endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace N+1 WorkOS API calls for member counts with a single batch SQL query against the local organization_memberships table. This eliminates the serialized HTTP calls that were causing 2-3 second load times. The organization_memberships table is kept in sync via WorkOS webhooks, so member counts remain accurate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/gentle-pianos-spend.md | 2 + server/src/routes/admin.ts | 2 +- server/src/routes/admin/prospects.ts | 124 +++++++++++++-------------- 3 files changed, 61 insertions(+), 67 deletions(-) create mode 100644 .changeset/gentle-pianos-spend.md diff --git a/.changeset/gentle-pianos-spend.md b/.changeset/gentle-pianos-spend.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/gentle-pianos-spend.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index cb8b29a60..e2092a9c6 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -82,7 +82,7 @@ export function createAdminRouter(): { pageRouter: Router; apiRouter: Router } { // ========================================================================= // Prospect management routes - setupProspectRoutes(apiRouter, { workos }); + setupProspectRoutes(apiRouter); // Organization detail and management routes setupOrganizationRoutes(pageRouter, apiRouter, { workos }); diff --git a/server/src/routes/admin/prospects.ts b/server/src/routes/admin/prospects.ts index f996cf62d..219121a65 100644 --- a/server/src/routes/admin/prospects.ts +++ b/server/src/routes/admin/prospects.ts @@ -4,7 +4,6 @@ */ import { Router } from "express"; -import { WorkOS } from "@workos-inc/node"; import { getPool } from "../../db/client.js"; import { createLogger } from "../../logger.js"; import { requireAuth, requireAdmin } from "../../middleware/auth.js"; @@ -13,15 +12,7 @@ import { createProspect } from "../../services/prospect.js"; const logger = createLogger("admin-prospects"); -interface ProspectRoutesConfig { - workos: WorkOS | null; -} - -export function setupProspectRoutes( - apiRouter: Router, - config: ProspectRoutesConfig -): void { - const { workos } = config; +export function setupProspectRoutes(apiRouter: Router): void { // GET /api/admin/prospects - List all prospects with action-based views apiRouter.get("/prospects", requireAuth, requireAdmin, async (req, res) => { @@ -359,6 +350,20 @@ export function setupProspectRoutes( }]) ); + // Get member counts from local organization_memberships table (avoids N+1 WorkOS API calls) + const memberCountsResult = await pool.query( + ` + SELECT workos_organization_id, COUNT(*) as member_count + FROM organization_memberships + WHERE workos_organization_id = ANY($1) + GROUP BY workos_organization_id + `, + [orgIds] + ); + const memberCountMap = new Map( + memberCountsResult.rows.map((r) => [r.workos_organization_id, parseInt(r.member_count)]) + ); + // Batch fetch pending invoices for orgs with Stripe customers const orgsWithStripe = result.rows.filter((r) => r.stripe_customer_id); const pendingInvoicesMap = new Map>>(); @@ -384,63 +389,50 @@ export function setupProspectRoutes( } } - // Enrich with WorkOS membership count and engagement level - const prospects = await Promise.all( - result.rows.map(async (row) => { - let memberCount = 0; - try { - if (workos) { - const memberships = - await workos.userManagement.listOrganizationMemberships({ - organizationId: row.workos_organization_id, - }); - memberCount = memberships.data?.length || 0; - } - } catch { - // Org might not exist in WorkOS yet or other error - } - - // Calculate engagement level - const wgCount = wgCountMap.get(row.workos_organization_id) || 0; - const recentActivityCount = - activityCountMap.get(row.workos_organization_id) || 0; - const pendingInvoices = pendingInvoicesMap.get(row.workos_organization_id) || []; - - let engagementLevel = 1; // Base level - exists - const engagementReasons: string[] = []; - - if (pendingInvoices.length > 0) { - engagementLevel = 5; - const totalAmount = pendingInvoices.reduce((sum, inv) => sum + inv.amount_due, 0); - engagementReasons.push(`Open invoice: $${(totalAmount / 100).toLocaleString()}`); - } else if (wgCount > 0) { - engagementLevel = 4; - engagementReasons.push(`In ${wgCount} working group(s)`); - } else if (memberCount > 0) { - engagementLevel = 3; - engagementReasons.push(`${memberCount} team member(s)`); - } else if (recentActivityCount > 0) { - engagementLevel = 2; - engagementReasons.push("Recent contact"); - } + // Enrich with membership count and engagement level (using local data instead of N+1 WorkOS API calls) + const prospects = result.rows.map((row) => { + const memberCount = memberCountMap.get(row.workos_organization_id) || 0; + + // Calculate engagement level + const wgCount = wgCountMap.get(row.workos_organization_id) || 0; + const recentActivityCount = + activityCountMap.get(row.workos_organization_id) || 0; + const pendingInvoices = pendingInvoicesMap.get(row.workos_organization_id) || []; + + let engagementLevel = 1; // Base level - exists + const engagementReasons: string[] = []; + + if (pendingInvoices.length > 0) { + engagementLevel = 5; + const totalAmount = pendingInvoices.reduce((sum, inv) => sum + inv.amount_due, 0); + engagementReasons.push(`Open invoice: $${(totalAmount / 100).toLocaleString()}`); + } else if (wgCount > 0) { + engagementLevel = 4; + engagementReasons.push(`In ${wgCount} working group(s)`); + } else if (memberCount > 0) { + engagementLevel = 3; + engagementReasons.push(`${memberCount} team member(s)`); + } else if (recentActivityCount > 0) { + engagementLevel = 2; + engagementReasons.push("Recent contact"); + } - return { - ...row, - member_count: memberCount, - has_members: memberCount > 0, - working_group_count: wgCount, - engagement_level: engagementLevel, - engagement_reasons: engagementReasons, - stakeholders: stakeholdersMap.get(row.workos_organization_id) || [], - slack_user_count: slackUserCountMap.get(row.workos_organization_id) || 0, - domains: domainsMap.get(row.workos_organization_id) || [], - last_activity: lastActivityMap.get(row.workos_organization_id) || null, - pending_steps: pendingStepsMap.get(row.workos_organization_id) || { pending: 0, overdue: 0 }, - recent_activity_count: recentActivityCount, - pending_invoices: pendingInvoices, - }; - }) - ); + return { + ...row, + member_count: memberCount, + has_members: memberCount > 0, + working_group_count: wgCount, + engagement_level: engagementLevel, + engagement_reasons: engagementReasons, + stakeholders: stakeholdersMap.get(row.workos_organization_id) || [], + slack_user_count: slackUserCountMap.get(row.workos_organization_id) || 0, + domains: domainsMap.get(row.workos_organization_id) || [], + last_activity: lastActivityMap.get(row.workos_organization_id) || null, + pending_steps: pendingStepsMap.get(row.workos_organization_id) || { pending: 0, overdue: 0 }, + recent_activity_count: recentActivityCount, + pending_invoices: pendingInvoices, + }; + }); // Filter by engagement level for specific views let filteredProspects = prospects;