From 10da02c8e915fcbea3cfdb2b04bc911b0444c1d7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Feb 2026 10:11:25 -0500 Subject: [PATCH 1/4] fix(admin): Improve org merge completion message clarity The merge UI previously showed "0 records moved" which was misleading when all records were duplicates. Now shows duplicates skipped count and confirms secondary organization deletion. Fixes #933 Co-Authored-By: Claude Opus 4.5 --- .changeset/eleven-icons-learn.md | 4 ++++ server/public/admin-domain-health.html | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .changeset/eleven-icons-learn.md diff --git a/.changeset/eleven-icons-learn.md b/.changeset/eleven-icons-learn.md new file mode 100644 index 0000000000..badfdcc240 --- /dev/null +++ b/.changeset/eleven-icons-learn.md @@ -0,0 +1,4 @@ +--- +--- + +Improve org merge completion message to show duplicates skipped and confirm secondary org deletion diff --git a/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index e7ee4ff44e..61cae52460 100644 --- a/server/public/admin-domain-health.html +++ b/server/public/admin-domain-health.html @@ -1674,7 +1674,20 @@

Merging organizations...

} const result = await response.json(); - const totalMoved = result.tables_merged.reduce((sum, t) => sum + t.rows_moved, 0); + const tablesMerged = result.tables_merged || []; + const totalMoved = tablesMerged.reduce((sum, t) => sum + (t.rows_moved || 0), 0); + const totalSkipped = tablesMerged.reduce((sum, t) => sum + (t.rows_skipped_duplicate || 0), 0); + + // Build summary message + let summaryParts = []; + if (totalMoved > 0) { + summaryParts.push(`${totalMoved} record${totalMoved !== 1 ? 's' : ''} moved`); + } + if (totalSkipped > 0) { + summaryParts.push(`${totalSkipped} duplicate${totalSkipped !== 1 ? 's' : ''} skipped`); + } + summaryParts.push('secondary organization deleted'); + const summaryText = summaryParts.join(', '); // Check if there are more orgs to merge if (remainingOrgIds && remainingOrgIds.length > 0) { @@ -1683,7 +1696,7 @@

Merging organizations...

Merge Complete

-

Successfully merged. ${totalMoved} records moved.

+

Successfully merged: ${summaryText}.

There are ${remainingCount} more organization(s) to merge.

@@ -1706,7 +1719,7 @@

Merg

All Merges Complete

-

Successfully merged all organizations. ${totalMoved} records moved in this merge.

+

Successfully merged: ${summaryText}.

From 3c946b04b747fbc8090389827836d8366a197440 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Feb 2026 10:19:56 -0500 Subject: [PATCH 2/4] fix(team): Paginate WorkOS memberships to fix potential members list The domain-users endpoint only fetched the first page of WorkOS memberships (default 10), causing users beyond page 1 to incorrectly appear as "Potential Members" even when already members. Now paginates through all memberships with limit=100 per page. Co-Authored-By: Claude Opus 4.5 --- server/src/routes/organizations.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/server/src/routes/organizations.ts b/server/src/routes/organizations.ts index c812873159..706b6e34c4 100644 --- a/server/src/routes/organizations.ts +++ b/server/src/routes/organizations.ts @@ -620,21 +620,27 @@ export function createOrganizationsRouter(): Router { const domains = domainsResult.rows.map(r => r.domain.toLowerCase()); - // Get current org members' emails - const allMemberships = await workos!.userManagement.listOrganizationMemberships({ - organizationId: orgId, - }); + // Get current org members' emails (paginate to get all) const memberEmails = new Set(); - for (const membership of allMemberships.data) { - try { - const memberUser = await workos!.userManagement.getUser(membership.userId); - if (memberUser.email) { - memberEmails.add(memberUser.email.toLowerCase()); + let after: string | undefined; + do { + const membershipsPage = await workos!.userManagement.listOrganizationMemberships({ + organizationId: orgId, + after, + limit: 100, + }); + for (const membership of membershipsPage.data) { + try { + const memberUser = await workos!.userManagement.getUser(membership.userId); + if (memberUser.email) { + memberEmails.add(memberUser.email.toLowerCase()); + } + } catch { + // Skip if can't fetch user } - } catch { - // Skip if can't fetch user } - } + after = membershipsPage.listMetadata?.after ?? undefined; + } while (after) // Get pending join request emails for this org const joinRequestsResult = await pool.query( From dc140fff740d02b525648a2a80590ff5edc4395b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Feb 2026 10:34:20 -0500 Subject: [PATCH 3/4] fix(merge): Delete secondary organization from WorkOS after merge The merge was only deleting from local DB, causing orgs to reappear when syncFromWorkOS ran on server restart. Now also deletes the secondary organization from WorkOS after the local merge completes. If WorkOS deletion fails, the local merge still succeeds but a warning is included in the response. Co-Authored-By: Claude Opus 4.5 --- server/src/addie/mcp/admin-tools.ts | 1 + server/src/db/org-merge-db.ts | 27 ++++++++++++++++++++++++++- server/src/routes/admin/cleanup.ts | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 2cadacea3e..c8395928f4 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -3882,6 +3882,7 @@ Use add_committee_leader to assign a leader.`; primaryOrgId, secondaryOrgId, mergedBy, + workos, stripeCustomerResolution ? { stripeCustomerResolution } : undefined ); diff --git a/server/src/db/org-merge-db.ts b/server/src/db/org-merge-db.ts index 20ead8c497..e570964225 100644 --- a/server/src/db/org-merge-db.ts +++ b/server/src/db/org-merge-db.ts @@ -12,6 +12,7 @@ import { getPool } from './client.js'; import { createLogger } from '../logger.js'; +import { WorkOS } from '@workos-inc/node'; const logger = createLogger('org-merge-db'); @@ -28,6 +29,7 @@ export interface MergeSummary { prospect_notes_merged: boolean; enrichment_data_preserved: boolean; stripe_customer_action: 'kept_primary' | 'moved_from_secondary' | 'none' | 'conflict_unresolved' | null; + workos_org_deleted: boolean; warnings: string[]; } @@ -39,6 +41,7 @@ export type StripeCustomerResolution = 'keep_primary' | 'use_secondary' | 'keep_ * @param primaryOrgId - The organization to keep (merge into) * @param secondaryOrgId - The organization to remove (merge from) * @param mergedBy - WorkOS user ID of person initiating the merge + * @param workos - WorkOS client instance for deleting the secondary org * @param options.stripeCustomerResolution - How to handle stripe_customer_id conflict * @returns Summary of the merge operation */ @@ -46,6 +49,7 @@ export async function mergeOrganizations( primaryOrgId: string, secondaryOrgId: string, mergedBy: string, + workos: WorkOS, options?: { stripeCustomerResolution?: StripeCustomerResolution; } @@ -62,6 +66,7 @@ export async function mergeOrganizations( prospect_notes_merged: false, enrichment_data_preserved: false, stripe_customer_action: null, + workos_org_deleted: false, warnings: [], }; @@ -688,7 +693,7 @@ export async function mergeOrganizations( ); // ===================================================== - // 24. Delete the secondary organization + // 24. Delete the secondary organization from local DB // ===================================================== await client.query( `DELETE FROM organizations WHERE workos_organization_id = $1`, @@ -698,11 +703,31 @@ export async function mergeOrganizations( // Commit transaction await client.query('COMMIT'); + // ===================================================== + // 25. Delete the secondary organization from WorkOS + // ===================================================== + // This is done after commit so local changes persist even if WorkOS fails + try { + await workos.organizations.deleteOrganization(secondaryOrgId); + summary.workos_org_deleted = true; + logger.info({ secondaryOrgId }, 'Deleted secondary organization from WorkOS'); + } catch (workosError) { + // Log but don't fail - local merge succeeded + logger.error( + { error: workosError, secondaryOrgId }, + 'Failed to delete secondary organization from WorkOS - manual cleanup may be required' + ); + summary.warnings.push( + 'Failed to delete secondary organization from WorkOS. The organization may need to be manually deleted in WorkOS Dashboard.' + ); + } + logger.info( { primaryOrgId, secondaryOrgId, totalMoved: summary.tables_merged.reduce((sum, t) => sum + t.rows_moved, 0), + workosDeleted: summary.workos_org_deleted, }, 'Organization merge completed successfully' ); diff --git a/server/src/routes/admin/cleanup.ts b/server/src/routes/admin/cleanup.ts index ac410735de..ba0890b79c 100644 --- a/server/src/routes/admin/cleanup.ts +++ b/server/src/routes/admin/cleanup.ts @@ -13,6 +13,7 @@ import { import { isLushaConfigured } from "../../services/lusha.js"; import { mergeOrganizations, previewMerge, StripeCustomerResolution } from "../../db/org-merge-db.js"; import { getPool } from "../../db/client.js"; +import { workos } from "../../auth/workos-client.js"; const logger = createLogger("admin-cleanup"); @@ -303,6 +304,7 @@ export function setupCleanupRoutes(apiRouter: Router): void { primary_org_id, secondary_org_id, userId, + workos, stripe_customer_resolution ? { stripeCustomerResolution: stripe_customer_resolution } : undefined ); From 2c47cf79ff25b38df04c618dd7014c38f737f807 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 2 Feb 2026 11:04:36 -0500 Subject: [PATCH 4/4] fix(merge): Allow merging personal workspace into company MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously all personal workspace merges were blocked. Now: - Allow: personal INTO company (user joins company) - Allow: personal INTO personal (consolidate duplicates) - Block: company INTO personal (would lose company identity) This fixes scenarios like Pia's Workspace → Celtra. Co-Authored-By: Claude Opus 4.5 --- server/src/db/org-merge-db.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/db/org-merge-db.ts b/server/src/db/org-merge-db.ts index e570964225..46fcaa5e05 100644 --- a/server/src/db/org-merge-db.ts +++ b/server/src/db/org-merge-db.ts @@ -102,9 +102,10 @@ export async function mergeOrganizations( throw new Error('Could not load organization details'); } - // Block merging personal workspaces - if (primaryOrg.is_personal || secondaryOrg.is_personal) { - throw new Error('Cannot merge personal workspaces. Personal workspaces represent individual users and should not be merged with company organizations.'); + // Block merging a company INTO a personal workspace (would lose company identity) + // But allow: personal INTO company, personal INTO personal + if (primaryOrg.is_personal && !secondaryOrg.is_personal) { + throw new Error('Cannot merge a company organization into a personal workspace. The company would lose its identity. Instead, merge the personal workspace into the company.'); } logger.info(