From c43bac632aae26e620a4c1e27257b8743f54288e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 1 Jan 2026 07:28:52 -0400 Subject: [PATCH 1/3] Remove unused auto-matching script and endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzzy matching produced too many false positives. Manual linking via /admin/billing is the intended workflow. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/olive-buses-doubt.md | 2 + scripts/link-stripe-customers.ts | 247 ------------------------------- server/src/http.ts | 174 ---------------------- 3 files changed, 2 insertions(+), 421 deletions(-) create mode 100644 .changeset/olive-buses-doubt.md delete mode 100644 scripts/link-stripe-customers.ts diff --git a/.changeset/olive-buses-doubt.md b/.changeset/olive-buses-doubt.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/olive-buses-doubt.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/scripts/link-stripe-customers.ts b/scripts/link-stripe-customers.ts deleted file mode 100644 index e79c148bab..0000000000 --- a/scripts/link-stripe-customers.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Script to link Stripe customers to WorkOS organizations - * - * Usage: - * npx tsx scripts/link-stripe-customers.ts --dry-run # Preview matches - * npx tsx scripts/link-stripe-customers.ts --apply # Apply the links - */ - -import Stripe from 'stripe'; -import { Pool } from 'pg'; -import 'dotenv/config'; - -const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; -const DATABASE_URL = process.env.DATABASE_URL; - -if (!STRIPE_SECRET_KEY) { - console.error('STRIPE_SECRET_KEY is required'); - process.exit(1); -} - -if (!DATABASE_URL) { - console.error('DATABASE_URL is required'); - process.exit(1); -} - -const stripe = new Stripe(STRIPE_SECRET_KEY); -const pool = new Pool({ connectionString: DATABASE_URL }); - -interface StripeCustomerInfo { - id: string; - name: string | null; - email: string | null; - metadata: Record; - created: number; - subscriptions?: { total_count: number }; -} - -interface OrgInfo { - workos_organization_id: string; - name: string; - email_domain: string | null; - stripe_customer_id: string | null; -} - -interface ProposedLink { - stripe_customer_id: string; - stripe_name: string | null; - stripe_email: string | null; - org_id: string; - org_name: string; - match_type: 'exact_name' | 'fuzzy_name' | 'email_domain' | 'manual'; - confidence: 'high' | 'medium' | 'low'; -} - -function normalizeString(s: string | null): string { - if (!s) return ''; - return s.toLowerCase().trim() - .replace(/[^a-z0-9]/g, '') // Remove special chars - .replace(/inc$|llc$|ltd$|corp$|corporation$|company$|co$/g, ''); // Remove suffixes -} - -function extractDomain(email: string | null): string | null { - if (!email) return null; - const match = email.match(/@([^@]+)$/); - return match ? match[1].toLowerCase() : null; -} - -function fuzzyMatch(a: string, b: string): number { - const normA = normalizeString(a); - const normB = normalizeString(b); - - if (normA === normB) return 1.0; - if (normA.includes(normB) || normB.includes(normA)) return 0.8; - - // Simple Levenshtein-based similarity for short strings - if (normA.length < 3 || normB.length < 3) return 0; - - const longer = normA.length > normB.length ? normA : normB; - const shorter = normA.length > normB.length ? normB : normA; - - if (longer.startsWith(shorter) || longer.endsWith(shorter)) return 0.7; - - return 0; -} - -async function main() { - const args = process.argv.slice(2); - const dryRun = args.includes('--dry-run') || !args.includes('--apply'); - - console.log(`\nšŸ”— Stripe Customer Linker ${dryRun ? '(DRY RUN)' : '(APPLYING CHANGES)'}\n`); - - // Fetch all orgs without stripe_customer_id - const orgsResult = await pool.query(` - SELECT workos_organization_id, name, email_domain, stripe_customer_id - FROM organizations - WHERE is_personal = false - ORDER BY name - `); - - const orgs = orgsResult.rows; - const unlinkedOrgs = orgs.filter(o => !o.stripe_customer_id); - const linkedOrgs = orgs.filter(o => o.stripe_customer_id); - - console.log(`šŸ“Š Organizations: ${orgs.length} total, ${linkedOrgs.length} already linked, ${unlinkedOrgs.length} unlinked\n`); - - // Fetch all Stripe customers - const stripeCustomers: StripeCustomerInfo[] = []; - console.log('šŸ“„ Fetching Stripe customers...'); - - for await (const customer of stripe.customers.list({ limit: 100, expand: ['data.subscriptions'] })) { - stripeCustomers.push({ - id: customer.id, - name: customer.name, - email: customer.email, - metadata: customer.metadata || {}, - created: customer.created, - subscriptions: customer.subscriptions ? { total_count: customer.subscriptions.data.length } : undefined, - }); - } - - console.log(`šŸ“„ Found ${stripeCustomers.length} Stripe customers\n`); - - // Find customers that are already linked - const alreadyLinkedCustomerIds = new Set(linkedOrgs.map(o => o.stripe_customer_id).filter(Boolean)); - const unlinkedCustomers = stripeCustomers.filter(c => !alreadyLinkedCustomerIds.has(c.id)); - - console.log(`šŸ” ${unlinkedCustomers.length} Stripe customers need linking\n`); - - // Try to match unlinked customers to unlinked orgs - const proposedLinks: ProposedLink[] = []; - const unmatchedCustomers: StripeCustomerInfo[] = []; - - for (const customer of unlinkedCustomers) { - let bestMatch: { org: OrgInfo; type: ProposedLink['match_type']; confidence: ProposedLink['confidence'] } | null = null; - - // Try exact name match first - for (const org of unlinkedOrgs) { - const score = fuzzyMatch(customer.name || '', org.name); - - if (score === 1.0) { - bestMatch = { org, type: 'exact_name', confidence: 'high' }; - break; - } else if (score >= 0.7 && (!bestMatch || bestMatch.confidence !== 'high')) { - bestMatch = { org, type: 'fuzzy_name', confidence: 'medium' }; - } - } - - // Try email domain match if no name match - if (!bestMatch || bestMatch.confidence !== 'high') { - const customerDomain = extractDomain(customer.email); - if (customerDomain) { - for (const org of unlinkedOrgs) { - if (org.email_domain && org.email_domain.toLowerCase() === customerDomain) { - if (!bestMatch || bestMatch.confidence === 'low') { - bestMatch = { org, type: 'email_domain', confidence: 'medium' }; - } - } - } - } - } - - if (bestMatch) { - proposedLinks.push({ - stripe_customer_id: customer.id, - stripe_name: customer.name, - stripe_email: customer.email, - org_id: bestMatch.org.workos_organization_id, - org_name: bestMatch.org.name, - match_type: bestMatch.type, - confidence: bestMatch.confidence, - }); - // Remove from unlinked to prevent duplicate matches - const idx = unlinkedOrgs.findIndex(o => o.workos_organization_id === bestMatch!.org.workos_organization_id); - if (idx !== -1) unlinkedOrgs.splice(idx, 1); - } else { - unmatchedCustomers.push(customer); - } - } - - // Print proposed links - if (proposedLinks.length > 0) { - console.log('āœ… PROPOSED LINKS:\n'); - console.log('| Stripe Customer | Stripe Name | Org Name | Match Type | Confidence |'); - console.log('|-----------------|-------------|----------|------------|------------|'); - - for (const link of proposedLinks) { - const custId = link.stripe_customer_id.slice(0, 15) + '...'; - const stripeName = (link.stripe_name || 'N/A').slice(0, 20); - const orgName = link.org_name.slice(0, 20); - console.log(`| ${custId} | ${stripeName.padEnd(20)} | ${orgName.padEnd(20)} | ${link.match_type.padEnd(12)} | ${link.confidence.padEnd(10)} |`); - } - console.log(''); - } - - // Print unmatched customers - if (unmatchedCustomers.length > 0) { - console.log(`\nāš ļø UNMATCHED STRIPE CUSTOMERS (${unmatchedCustomers.length}):\n`); - for (const c of unmatchedCustomers) { - const hasSubs = c.subscriptions && c.subscriptions.total_count > 0; - console.log(` ${c.id}: "${c.name || 'No name'}" <${c.email || 'no email'}> ${hasSubs ? '(HAS SUBSCRIPTIONS)' : ''}`); - } - console.log(''); - } - - // Print remaining unlinked orgs - if (unlinkedOrgs.length > 0) { - console.log(`\nšŸ“‹ REMAINING UNLINKED ORGS (${unlinkedOrgs.length}):\n`); - for (const o of unlinkedOrgs.slice(0, 20)) { - console.log(` ${o.workos_organization_id}: "${o.name}" (domain: ${o.email_domain || 'none'})`); - } - if (unlinkedOrgs.length > 20) { - console.log(` ... and ${unlinkedOrgs.length - 20} more`); - } - console.log(''); - } - - // Apply changes if not dry run - if (!dryRun && proposedLinks.length > 0) { - console.log('\nšŸš€ Applying links...\n'); - - let applied = 0; - let failed = 0; - - for (const link of proposedLinks) { - try { - await pool.query( - 'UPDATE organizations SET stripe_customer_id = $1 WHERE workos_organization_id = $2', - [link.stripe_customer_id, link.org_id] - ); - console.log(` āœ… Linked ${link.stripe_customer_id} → ${link.org_name}`); - applied++; - } catch (err) { - console.log(` āŒ Failed to link ${link.stripe_customer_id}: ${err}`); - failed++; - } - } - - console.log(`\nšŸ“Š Applied ${applied} links, ${failed} failed\n`); - console.log('Run "Sync from Stripe" in the admin panel to pull revenue data.\n'); - } else if (proposedLinks.length > 0) { - console.log('\nšŸ’” Run with --apply to apply these links\n'); - } - - await pool.end(); -} - -main().catch(console.error); diff --git a/server/src/http.ts b/server/src/http.ts index fef4c347c0..ae26c47852 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -3352,180 +3352,6 @@ export class HTTPServer { } }); - // POST /api/admin/link-stripe-customers - Link Stripe customers to orgs by name/email matching - this.app.post('/api/admin/link-stripe-customers', requireAuth, requireAdmin, async (req, res) => { - const dryRun = req.query.dry_run === 'true'; - - if (!stripe) { - return res.status(400).json({ error: 'Stripe not configured' }); - } - - try { - const pool = getPool(); - - // Helper functions - const normalizeString = (s: string | null): string => { - if (!s) return ''; - return s.toLowerCase().trim() - .replace(/[^a-z0-9]/g, '') - .replace(/inc$|llc$|ltd$|corp$|corporation$|company$|co$/g, ''); - }; - - const extractDomain = (email: string | null): string | null => { - if (!email) return null; - const match = email.match(/@([^@]+)$/); - return match ? match[1].toLowerCase() : null; - }; - - const fuzzyMatch = (a: string, b: string): number => { - const normA = normalizeString(a); - const normB = normalizeString(b); - if (normA === normB) return 1.0; - if (normA.includes(normB) || normB.includes(normA)) return 0.8; - if (normA.length < 3 || normB.length < 3) return 0; - const longer = normA.length > normB.length ? normA : normB; - const shorter = normA.length > normB.length ? normB : normA; - if (longer.startsWith(shorter) || longer.endsWith(shorter)) return 0.7; - return 0; - }; - - // Fetch all orgs - const orgsResult = await pool.query(` - SELECT workos_organization_id, name, email_domain, stripe_customer_id - FROM organizations - WHERE is_personal = false - ORDER BY name - `); - - const orgs = orgsResult.rows; - const unlinkedOrgs = orgs.filter((o: { stripe_customer_id: string | null }) => !o.stripe_customer_id); - const linkedCustomerIds = new Set(orgs.map((o: { stripe_customer_id: string | null }) => o.stripe_customer_id).filter(Boolean)); - - // Fetch all Stripe customers - const stripeCustomers: Array<{ id: string; name: string | null; email: string | null }> = []; - for await (const customer of stripe.customers.list({ limit: 100 })) { - if (!linkedCustomerIds.has(customer.id)) { - stripeCustomers.push({ - id: customer.id, - name: customer.name ?? null, - email: customer.email ?? null, - }); - } - } - - // Match customers to orgs - interface ProposedLink { - stripe_customer_id: string; - stripe_name: string | null; - stripe_email: string | null; - org_id: string; - org_name: string; - match_type: string; - confidence: string; - } - - const proposedLinks: ProposedLink[] = []; - const unmatchedCustomers: typeof stripeCustomers = []; - const remainingOrgs = [...unlinkedOrgs]; - - for (const customer of stripeCustomers) { - let bestMatch: { org: typeof orgs[0]; type: string; confidence: string } | null = null; - - for (const org of remainingOrgs) { - const score = fuzzyMatch(customer.name || '', org.name); - if (score === 1.0) { - bestMatch = { org, type: 'exact_name', confidence: 'high' }; - break; - } else if (score >= 0.7 && (!bestMatch || bestMatch.confidence !== 'high')) { - bestMatch = { org, type: 'fuzzy_name', confidence: 'medium' }; - } - } - - // Try email domain match - if (!bestMatch || bestMatch.confidence !== 'high') { - const customerDomain = extractDomain(customer.email); - if (customerDomain) { - for (const org of remainingOrgs) { - if (org.email_domain && org.email_domain.toLowerCase() === customerDomain) { - if (!bestMatch || bestMatch.confidence === 'low') { - bestMatch = { org, type: 'email_domain', confidence: 'medium' }; - } - } - } - } - } - - if (bestMatch) { - proposedLinks.push({ - stripe_customer_id: customer.id, - stripe_name: customer.name, - stripe_email: customer.email, - org_id: bestMatch.org.workos_organization_id, - org_name: bestMatch.org.name, - match_type: bestMatch.type, - confidence: bestMatch.confidence, - }); - const idx = remainingOrgs.findIndex(o => o.workos_organization_id === bestMatch!.org.workos_organization_id); - if (idx !== -1) remainingOrgs.splice(idx, 1); - } else { - unmatchedCustomers.push(customer); - } - } - - // Apply links if not dry run - let applied = 0; - let failed = 0; - - if (!dryRun) { - for (const link of proposedLinks) { - try { - await pool.query( - 'UPDATE organizations SET stripe_customer_id = $1 WHERE workos_organization_id = $2', - [link.stripe_customer_id, link.org_id] - ); - applied++; - } catch (err) { - logger.error({ err, link }, 'Failed to link Stripe customer'); - failed++; - } - } - } - - logger.info({ - dryRun, - totalOrgs: orgs.length, - unlinkedOrgs: unlinkedOrgs.length, - stripeCustomers: stripeCustomers.length, - proposedLinks: proposedLinks.length, - unmatchedCustomers: unmatchedCustomers.length, - applied, - failed, - }, 'Link Stripe customers completed'); - - res.json({ - success: true, - dry_run: dryRun, - total_orgs: orgs.length, - already_linked: orgs.length - unlinkedOrgs.length, - unlinked_stripe_customers: stripeCustomers.length, - proposed_links: proposedLinks, - unmatched_customers: unmatchedCustomers, - remaining_unlinked_orgs: remainingOrgs.length, - applied: dryRun ? 0 : applied, - failed: dryRun ? 0 : failed, - message: dryRun - ? `Found ${proposedLinks.length} potential links. Run with dry_run=false to apply.` - : `Applied ${applied} links, ${failed} failed.`, - }); - } catch (error) { - logger.error({ err: error }, 'Error linking Stripe customers'); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Failed to link customers', - }); - } - }); - // ======================================== // Perspectives Admin Routes // ======================================== From bb8caaba9e9520552ce8b97d10dfb59992cad9cb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 1 Jan 2026 07:36:59 -0400 Subject: [PATCH 2/3] Add admin page for manual Stripe customer linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/admin/stripe-customers to list all customers with payment totals - Add POST /api/admin/stripe-customers/:id/link for manual linking - Add GET /api/admin/organizations/search for org autocomplete - Create /admin/billing page with customer list and linking UI - Add "Stripe Linking" to admin sidebar under Billing šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/public/admin-billing.html | 560 +++++++++++++++++++++++++++++++ server/public/admin-sidebar.js | 1 + server/src/http.ts | 184 ++++++++++ 3 files changed, 745 insertions(+) create mode 100644 server/public/admin-billing.html diff --git a/server/public/admin-billing.html b/server/public/admin-billing.html new file mode 100644 index 0000000000..c70b4180ae --- /dev/null +++ b/server/public/admin-billing.html @@ -0,0 +1,560 @@ + + + + + + + Billing - Admin + + + + + + +
+ +
+

Stripe Customer Linking

+

Link Stripe customers to organizations for revenue tracking

+ + + + +
+
+
Total Customers
+
-
+
+
+
Linked
+
-
+
+
+
Unlinked
+
-
+
+
+
Unlinked with Payments
+
-
+
+
+ +
+
+
Stripe Customers
+ +
+ +
+ + + +
+ +
+
+

Loading Stripe customers...

+
+ + + + + + + + + + + + + + + +
+
+ + + + diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js index e992053543..f3ec2264bf 100644 --- a/server/public/admin-sidebar.js +++ b/server/public/admin-sidebar.js @@ -33,6 +33,7 @@ label: 'Billing', items: [ { href: '/admin/products', label: 'Products', icon: 'šŸ’³' }, + { href: '/admin/billing', label: 'Stripe Linking', icon: 'šŸ”—' }, ] }, { diff --git a/server/src/http.ts b/server/src/http.ts index ae26c47852..80503df5f8 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -3352,6 +3352,186 @@ export class HTTPServer { } }); + // GET /api/admin/stripe-customers - List all Stripe customers with link status and payment totals + this.app.get('/api/admin/stripe-customers', requireAuth, requireAdmin, async (req, res) => { + if (!stripe) { + return res.status(400).json({ error: 'Stripe not configured' }); + } + + try { + const pool = getPool(); + + // Get all orgs with stripe_customer_id + const orgsResult = await pool.query(` + SELECT workos_organization_id, name, stripe_customer_id + FROM organizations + WHERE stripe_customer_id IS NOT NULL + `); + const customerToOrg = new Map(orgsResult.rows.map(o => [o.stripe_customer_id, { id: o.workos_organization_id, name: o.name }])); + + // Fetch all Stripe customers with their payment totals + const customers: Array<{ + id: string; + name: string | null; + email: string | null; + created: number; + total_paid: number; + invoice_count: number; + linked_org: { id: string; name: string } | null; + }> = []; + + for await (const customer of stripe.customers.list({ limit: 100, expand: ['data.subscriptions'] })) { + // Get invoices for this customer to calculate total paid + let totalPaid = 0; + let invoiceCount = 0; + + for await (const invoice of stripe.invoices.list({ + customer: customer.id, + status: 'paid', + limit: 100, + })) { + totalPaid += invoice.amount_paid; + invoiceCount++; + } + + customers.push({ + id: customer.id, + name: customer.name ?? null, + email: customer.email ?? null, + created: customer.created, + total_paid: totalPaid, + invoice_count: invoiceCount, + linked_org: customerToOrg.get(customer.id) || null, + }); + } + + // Sort: unlinked with payments first, then by total_paid descending + customers.sort((a, b) => { + // Unlinked customers with payments come first + const aUnlinkedWithPayments = !a.linked_org && a.total_paid > 0; + const bUnlinkedWithPayments = !b.linked_org && b.total_paid > 0; + if (aUnlinkedWithPayments && !bUnlinkedWithPayments) return -1; + if (!aUnlinkedWithPayments && bUnlinkedWithPayments) return 1; + // Then by total paid descending + return b.total_paid - a.total_paid; + }); + + const unlinkedCount = customers.filter(c => !c.linked_org).length; + const unlinkedWithPayments = customers.filter(c => !c.linked_org && c.total_paid > 0).length; + + res.json({ + customers, + total: customers.length, + linked: customers.length - unlinkedCount, + unlinked: unlinkedCount, + unlinked_with_payments: unlinkedWithPayments, + }); + } catch (error) { + logger.error({ err: error }, 'Error fetching Stripe customers'); + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Failed to fetch customers', + }); + } + }); + + // POST /api/admin/stripe-customers/:customerId/link - Manually link a Stripe customer to an org + this.app.post('/api/admin/stripe-customers/:customerId/link', requireAuth, requireAdmin, async (req, res) => { + const { customerId } = req.params; + const { org_id } = req.body; + + if (!org_id) { + return res.status(400).json({ error: 'org_id is required' }); + } + + try { + const pool = getPool(); + + // Verify org exists + const orgResult = await pool.query( + 'SELECT workos_organization_id, name, stripe_customer_id FROM organizations WHERE workos_organization_id = $1', + [org_id] + ); + + if (orgResult.rows.length === 0) { + return res.status(404).json({ error: 'Organization not found' }); + } + + const org = orgResult.rows[0]; + + if (org.stripe_customer_id && org.stripe_customer_id !== customerId) { + return res.status(400).json({ + error: 'Organization already linked', + message: `This organization is already linked to a different Stripe customer (${org.stripe_customer_id})`, + }); + } + + // Check if customer is already linked to another org + const existingLink = await pool.query( + 'SELECT workos_organization_id, name FROM organizations WHERE stripe_customer_id = $1', + [customerId] + ); + + if (existingLink.rows.length > 0 && existingLink.rows[0].workos_organization_id !== org_id) { + return res.status(400).json({ + error: 'Customer already linked', + message: `This Stripe customer is already linked to "${existingLink.rows[0].name}"`, + }); + } + + // Link the customer + await pool.query( + 'UPDATE organizations SET stripe_customer_id = $1 WHERE workos_organization_id = $2', + [customerId, org_id] + ); + + logger.info({ customerId, orgId: org_id, orgName: org.name, adminEmail: req.user?.email }, 'Manually linked Stripe customer to org'); + + res.json({ + success: true, + message: `Linked Stripe customer ${customerId} to "${org.name}"`, + customer_id: customerId, + org_id, + org_name: org.name, + }); + } catch (error) { + logger.error({ err: error, customerId, org_id }, 'Error linking Stripe customer'); + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Failed to link customer', + }); + } + }); + + // GET /api/admin/organizations/search - Search organizations for linking + this.app.get('/api/admin/organizations/search', requireAuth, requireAdmin, async (req, res) => { + const query = req.query.q as string; + + if (!query || query.length < 2) { + return res.json({ organizations: [] }); + } + + try { + const pool = getPool(); + const result = await pool.query(` + SELECT workos_organization_id, name, email_domain, stripe_customer_id + FROM organizations + WHERE is_personal = false + AND (name ILIKE $1 OR email_domain ILIKE $1) + ORDER BY name + LIMIT 20 + `, [`%${query}%`]); + + res.json({ organizations: result.rows }); + } catch (error) { + logger.error({ err: error }, 'Error searching organizations'); + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Failed to search organizations', + }); + } + }); + // ======================================== // Perspectives Admin Routes // ======================================== @@ -4380,6 +4560,10 @@ Disallow: /api/admin/ await this.serveHtmlWithConfig(req, res, 'admin-audit.html'); }); + this.app.get('/admin/billing', requireAuth, requireAdmin, async (req, res) => { + await this.serveHtmlWithConfig(req, res, 'admin-billing.html'); + }); + this.app.get('/admin/perspectives', requireAuth, requireAdmin, async (req, res) => { await this.serveHtmlWithConfig(req, res, 'admin-perspectives.html'); }); From 8c5482fd3de74017037b62fdf6f58653c4f1f743 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 1 Jan 2026 08:14:13 -0400 Subject: [PATCH 3/3] Fix org search route conflict with :orgId param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed /api/admin/organizations/search to /api/admin/org-search to avoid route conflict with /api/admin/organizations/:orgId in admin.ts šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/public/admin-billing.html | 2 +- server/src/http.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/public/admin-billing.html b/server/public/admin-billing.html index c70b4180ae..7a97dc6d04 100644 --- a/server/public/admin-billing.html +++ b/server/public/admin-billing.html @@ -442,7 +442,7 @@

Stripe Customer Linking

clearTimeout(searchTimeout); searchTimeout = setTimeout(async () => { try { - const response = await fetch(`/api/admin/organizations/search?q=${encodeURIComponent(query)}`); + const response = await fetch(`/api/admin/org-search?q=${encodeURIComponent(query)}`); const data = await response.json(); if (data.organizations.length === 0) { diff --git a/server/src/http.ts b/server/src/http.ts index 80503df5f8..ad0782130f 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -3503,8 +3503,8 @@ export class HTTPServer { } }); - // GET /api/admin/organizations/search - Search organizations for linking - this.app.get('/api/admin/organizations/search', requireAuth, requireAdmin, async (req, res) => { + // GET /api/admin/org-search - Search organizations for linking + this.app.get('/api/admin/org-search', requireAuth, requireAdmin, async (req, res) => { const query = req.query.q as string; if (!query || query.length < 2) {