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

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..46fcaa5e05 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: [], }; @@ -97,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( @@ -688,7 +694,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 +704,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 ); 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(