Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/eleven-icons-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Improve org merge completion message to show duplicates skipped and confirm secondary org deletion
19 changes: 16 additions & 3 deletions server/public/admin-domain-health.html
Original file line number Diff line number Diff line change
Expand Up @@ -1674,7 +1674,20 @@ <h3 style="margin-bottom: var(--space-4);">Merging organizations...</h3>
}

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) {
Expand All @@ -1683,7 +1696,7 @@ <h3 style="margin-bottom: var(--space-4);">Merging organizations...</h3>
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--color-surface-overlay); display: flex; align-items: center; justify-content: center; z-index: 1000;">
<div style="background: var(--color-bg-card); border-radius: var(--radius-lg); padding: var(--space-6); max-width: 600px; width: 90%;">
<h3 style="color: var(--color-success-600); margin-bottom: var(--space-4);">Merge Complete</h3>
<p>Successfully merged. ${totalMoved} records moved.</p>
<p>Successfully merged: ${summaryText}.</p>
<p style="margin-top: var(--space-3);">There are ${remainingCount} more organization(s) to merge.</p>
<div style="margin-top: var(--space-4); display: flex; justify-content: space-between;">
<button class="btn btn-secondary" onclick="closeMergeModal(); loadDomainHealth();">Done for Now</button>
Expand All @@ -1706,7 +1719,7 @@ <h3 style="color: var(--color-success-600); margin-bottom: var(--space-4);">Merg
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--color-surface-overlay); display: flex; align-items: center; justify-content: center; z-index: 1000;">
<div style="background: var(--color-bg-card); border-radius: var(--radius-lg); padding: var(--space-6); max-width: 600px; width: 90%;">
<h3 style="color: var(--color-success-600); margin-bottom: var(--space-4);">All Merges Complete</h3>
<p>Successfully merged all organizations. ${totalMoved} records moved in this merge.</p>
<p>Successfully merged: ${summaryText}.</p>
<div style="margin-top: var(--space-4); text-align: right;">
<button class="btn btn-primary" onclick="closeMergeModal(); loadDomainHealth();">Done</button>
</div>
Expand Down
1 change: 1 addition & 0 deletions server/src/addie/mcp/admin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3882,6 +3882,7 @@ Use add_committee_leader to assign a leader.`;
primaryOrgId,
secondaryOrgId,
mergedBy,
workos,
stripeCustomerResolution ? { stripeCustomerResolution } : undefined
);

Expand Down
34 changes: 30 additions & 4 deletions server/src/db/org-merge-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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[];
}

Expand All @@ -39,13 +41,15 @@ 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
*/
export async function mergeOrganizations(
primaryOrgId: string,
secondaryOrgId: string,
mergedBy: string,
workos: WorkOS,
options?: {
stripeCustomerResolution?: StripeCustomerResolution;
}
Expand All @@ -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: [],
};

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`,
Expand All @@ -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'
);
Expand Down
2 changes: 2 additions & 0 deletions server/src/routes/admin/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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
);

Expand Down
30 changes: 18 additions & 12 deletions server/src/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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(
Expand Down