From 81582b1c5127e82c5dddb773dccbcb759c7e01c8 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 29 Jun 2026 17:21:21 -0400 Subject: [PATCH 01/23] fix(api): add merge-duplicate-user task to consolidate users after email domain migration --- .../tasks/people/merge-duplicate-user.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user.ts diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts new file mode 100644 index 0000000000..c802903343 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -0,0 +1,272 @@ +import { db } from '@db'; +import { logger, schemaTask, tags } from '@trigger.dev/sdk'; +import { z } from 'zod'; + +export const mergeDuplicateUser = schemaTask({ + id: 'merge-duplicate-user', + schema: z.object({ + organizationId: z.string(), + oldEmail: z.string().email(), + newEmail: z.string().email(), + }), + run: async ({ organizationId, oldEmail, newEmail }) => { + await tags.add([`org:${organizationId}`]); + + // ── 1. Resolve both users ──────────────────────────────────────────────── + + const [oldUser, newUser] = await Promise.all([ + db.user.findUnique({ where: { email: oldEmail } }), + db.user.findUnique({ where: { email: newEmail } }), + ]); + + if (!oldUser) { + throw new Error(`Old user not found: ${oldEmail}`); + } + if (!newUser) { + throw new Error(`New user not found: ${newEmail}`); + } + + logger.info('Resolved users', { + oldUserId: oldUser.id, + newUserId: newUser.id, + }); + + // ── 2. Resolve both members in this org ────────────────────────────────── + + const [oldMember, newMember] = await Promise.all([ + db.member.findFirst({ where: { userId: oldUser.id, organizationId } }), + db.member.findFirst({ where: { userId: newUser.id, organizationId } }), + ]); + + if (!oldMember) { + throw new Error( + `Old member not found for user ${oldUser.id} in org ${organizationId}`, + ); + } + if (!newMember) { + throw new Error( + `New member not found for user ${newUser.id} in org ${organizationId}`, + ); + } + + logger.info('Resolved members', { + oldMemberId: oldMember.id, + newMemberId: newMember.id, + }); + + // ── 3. Merge inside a transaction ──────────────────────────────────────── + + await db.$transaction( + async (tx) => { + const o = oldMember.id; + const n = newMember.id; + + // Policies: assigneeId, approverId, signedBy (String[] — replace in array) + await tx.policy.updateMany({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + await tx.policy.updateMany({ + where: { approverId: o }, + data: { approverId: n }, + }); + + // signedBy is a String[], use raw update to replace the member id in the array + const policiesWithSignature = await tx.policy.findMany({ + where: { signedBy: { has: o } }, + select: { id: true, signedBy: true }, + }); + for (const policy of policiesWithSignature) { + const updated = policy.signedBy.map((id) => (id === o ? n : id)); + await tx.policy.update({ + where: { id: policy.id }, + data: { signedBy: updated }, + }); + } + + // PolicyVersion: publishedById + await tx.policyVersion.updateMany({ + where: { publishedById: o }, + data: { publishedById: n }, + }); + + // Risk: assigneeId + await tx.risk.updateMany({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + + // Task: assigneeId, approverId + await tx.task.updateMany({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + await tx.task.updateMany({ + where: { approverId: o }, + data: { approverId: n }, + }); + + // TaskItem: assigneeId, createdById, updatedById + await tx.taskItem.updateMany({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + await tx.taskItem.updateMany({ + where: { createdById: o }, + data: { createdById: n }, + }); + await tx.taskItem.updateMany({ + where: { updatedById: o }, + data: { updatedById: n }, + }); + + // Vendor: assigneeId + await tx.vendor.updateMany({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + + // Finding: memberId (subject), createdById + await tx.finding.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + await tx.finding.updateMany({ + where: { createdById: o }, + data: { createdById: n }, + }); + + // FrameworkSyncOperation: performedById + await tx.frameworkSyncOperation.updateMany({ + where: { performedById: o }, + data: { performedById: n }, + }); + + // Comment: memberId + await tx.comment.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + + // AuditLog: memberId + await tx.auditLog.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + + // Device: memberId + await tx.device.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + + // BackgroundCheckRequest: memberId + await tx.backgroundCheckRequest.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + + // TrustAccessRequest: reviewerMemberId + await tx.trustAccessRequest.updateMany({ + where: { reviewerMemberId: o }, + data: { reviewerMemberId: n }, + }); + + // TrustAccessGrant: issuedByMemberId, revokedByMemberId + await tx.trustAccessGrant.updateMany({ + where: { issuedByMemberId: o }, + data: { issuedByMemberId: n }, + }); + await tx.trustAccessGrant.updateMany({ + where: { revokedByMemberId: o }, + data: { revokedByMemberId: n }, + }); + + // OffboardingChecklistCompletion: memberId + await tx.offboardingChecklistCompletion.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + + // OffboardingAccessRevocation: memberId, revokedById + await tx.offboardingAccessRevocation.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + await tx.offboardingAccessRevocation.updateMany({ + where: { revokedById: o }, + data: { revokedById: n }, + }); + + // EmployeeTrainingVideoCompletion: skip videos the old member already has + const existingCompletions = + await tx.employeeTrainingVideoCompletion.findMany({ + where: { memberId: o }, + select: { videoId: true }, + }); + + const newCompletions = + await tx.employeeTrainingVideoCompletion.findMany({ + where: { memberId: n }, + select: { id: true, videoId: true }, + }); + const newCompletedVideoIds = new Set( + newCompletions.map((c) => c.videoId), + ); + + const toMigrate = existingCompletions.filter( + (c) => !newCompletedVideoIds.has(c.videoId), + ); + + if (toMigrate.length > 0) { + await tx.employeeTrainingVideoCompletion.updateMany({ + where: { id: { in: toMigrate.map((c) => c.id) } }, + data: { memberId: n }, + }); + } + + logger.info('Re-pointed member relations', { + policiesWithSignature: policiesWithSignature.length, + trainingMigrated: toMigrate.length, + trainingDropped: toDrop.length, + }); + + // ── Delete old member ──────────────────────────────────────────────── + await tx.member.delete({ where: { id: o } }); + + // ── Migrate new user's OAuth accounts to old user ──────────────────── + await tx.account.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + + // ── Delete new user sessions and the user record ───────────────────── + await tx.session.deleteMany({ where: { userId: oldUser.id } }); + await tx.user.delete({ where: { id: oldUser.id } }); + + // ── Update pending invitations ─────────────────────────────────────── + await tx.invitation.updateMany({ + where: { email: oldEmail, organizationId }, + data: { email: newEmail }, + }); + }, + { timeout: 30000 }, + ); + + logger.info('Merge complete', { + organizationId, + oldEmail, + newEmail, + survivingUserId: oldUser.id, + survivingMemberId: oldMember.id, + }); + + return { + success: true, + survivingUserId: newUser.id, + survivingMemberId: newMember.id, + mergedUserId: oldUser.id, + mergedMemberId: oldMember.id, + }; + }, +}); From df9a837d0293b116df5870c8ae9863d29287037a Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 12:58:37 -0400 Subject: [PATCH 02/23] fix(api): create trigger job to find duplicate members and migrate --- .../tasks/people/merge-duplicate-user.ts | 8 +- .../tasks/people/migrate-org-email-domain.ts | 85 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index c802903343..d849208694 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -144,8 +144,8 @@ export const mergeDuplicateUser = schemaTask({ // Comment: memberId await tx.comment.updateMany({ - where: { memberId: o }, - data: { memberId: n }, + where: { authorId: o }, + data: { authorId: n }, }); // AuditLog: memberId @@ -202,7 +202,7 @@ export const mergeDuplicateUser = schemaTask({ const existingCompletions = await tx.employeeTrainingVideoCompletion.findMany({ where: { memberId: o }, - select: { videoId: true }, + select: { id: true, videoId: true }, }); const newCompletions = @@ -228,7 +228,7 @@ export const mergeDuplicateUser = schemaTask({ logger.info('Re-pointed member relations', { policiesWithSignature: policiesWithSignature.length, trainingMigrated: toMigrate.length, - trainingDropped: toDrop.length, + trainingDropped: newCompletions.length, }); // ── Delete old member ──────────────────────────────────────────────── diff --git a/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts new file mode 100644 index 0000000000..13eb9055b9 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts @@ -0,0 +1,85 @@ +import { db } from '@db'; +import { logger, schemaTask, tags } from '@trigger.dev/sdk'; +import { z } from 'zod'; + +import { mergeDuplicateUser } from './merge-duplicate-user'; + +export const migrateOrgEmailDomain = schemaTask({ + id: 'migrate-org-email-domain', + schema: z.object({ + organizationId: z.string(), + oldDomain: z.string().min(1), + newDomain: z.string().min(1), + }), + run: async ({ organizationId, oldDomain, newDomain }) => { + await tags.add([`org:${organizationId}`]); + + // Find all active members in the org with their user emails + const members = await db.member.findMany({ + where: { organizationId, isActive: true, deactivated: false }, + select: { + user: { select: { email: true } }, + }, + }); + + const oldDomainSuffix = `@${oldDomain}`; + const newDomainSuffix = `@${newDomain}`; + + // Build a set of new-domain emails for fast lookup + const newDomainEmails = new Set( + members + .map((m) => m.user.email) + .filter((email) => email.endsWith(newDomainSuffix)), + ); + + // Find members with old-domain email that have a matching new-domain counterpart + const duplicatePairs = members + .map((m) => m.user.email) + .filter((email) => email.endsWith(oldDomainSuffix)) + .map((oldEmail) => { + const username = oldEmail.slice(0, -oldDomainSuffix.length); + const newEmail = `${username}${newDomainSuffix}`; + return { oldEmail, newEmail }; + }) + .filter(({ newEmail }) => newDomainEmails.has(newEmail)); + + if (duplicatePairs.length === 0) { + logger.info('No duplicate email pairs found', { organizationId, oldDomain, newDomain }); + return { mergedCount: 0, pairs: [] }; + } + + logger.info(`Found ${duplicatePairs.length} duplicate pairs to merge`, { + organizationId, + pairs: duplicatePairs, + }); + + const results: Array<{ oldEmail: string; newEmail: string; ok: boolean; error?: string }> = []; + + for (const { oldEmail, newEmail } of duplicatePairs) { + const result = await mergeDuplicateUser.triggerAndWait({ + organizationId, + oldEmail, + newEmail, + }); + + if (result.ok) { + logger.info('Merged duplicate user', { oldEmail, newEmail }); + results.push({ oldEmail, newEmail, ok: true }); + } else { + logger.error('Failed to merge duplicate user', { oldEmail, newEmail, error: result.error }); + results.push({ oldEmail, newEmail, ok: false, error: String(result.error) }); + } + } + + const mergedCount = results.filter((r) => r.ok).length; + const failedCount = results.filter((r) => !r.ok).length; + + logger.info('Domain migration complete', { organizationId, mergedCount, failedCount }); + + return { + mergedCount, + failedCount, + pairs: results, + }; + }, +}); From 4bcfe574e01e85b5c2d5951cf4725bf64c9d2acb Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 13:12:20 -0400 Subject: [PATCH 03/23] fix(api): normalize emails and domains to lowercase before matching in migrate-org-email-domain --- .../tasks/people/migrate-org-email-domain.ts | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts index 13eb9055b9..8b703fb7d9 100644 --- a/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts +++ b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts @@ -14,6 +14,9 @@ export const migrateOrgEmailDomain = schemaTask({ run: async ({ organizationId, oldDomain, newDomain }) => { await tags.add([`org:${organizationId}`]); + const normalizedOldDomain = oldDomain.toLowerCase(); + const normalizedNewDomain = newDomain.toLowerCase(); + // Find all active members in the org with their user emails const members = await db.member.findMany({ where: { organizationId, isActive: true, deactivated: false }, @@ -22,26 +25,30 @@ export const migrateOrgEmailDomain = schemaTask({ }, }); - const oldDomainSuffix = `@${oldDomain}`; - const newDomainSuffix = `@${newDomain}`; + const oldDomainSuffix = `@${normalizedOldDomain}`; + const newDomainSuffix = `@${normalizedNewDomain}`; + + // Normalize all emails to lowercase before matching + const normalizedEmails = members.map((m) => ({ + original: m.user.email, + normalized: m.user.email.toLowerCase(), + })); - // Build a set of new-domain emails for fast lookup - const newDomainEmails = new Set( - members - .map((m) => m.user.email) - .filter((email) => email.endsWith(newDomainSuffix)), + // Build a map from normalized new-domain email → original email for lookup + const newDomainEmailMap = new Map( + normalizedEmails + .filter(({ normalized }) => normalized.endsWith(newDomainSuffix)) + .map(({ normalized, original }) => [normalized, original]), ); // Find members with old-domain email that have a matching new-domain counterpart - const duplicatePairs = members - .map((m) => m.user.email) - .filter((email) => email.endsWith(oldDomainSuffix)) - .map((oldEmail) => { - const username = oldEmail.slice(0, -oldDomainSuffix.length); - const newEmail = `${username}${newDomainSuffix}`; - return { oldEmail, newEmail }; - }) - .filter(({ newEmail }) => newDomainEmails.has(newEmail)); + const duplicatePairs = normalizedEmails + .filter(({ normalized }) => normalized.endsWith(oldDomainSuffix)) + .flatMap(({ normalized, original: oldEmail }) => { + const username = normalized.slice(0, -oldDomainSuffix.length); + const matchedNewEmail = newDomainEmailMap.get(`${username}${newDomainSuffix}`); + return matchedNewEmail ? [{ oldEmail, newEmail: matchedNewEmail }] : []; + }); if (duplicatePairs.length === 0) { logger.info('No duplicate email pairs found', { organizationId, oldDomain, newDomain }); From 1f9179240e39ca22f36f3df2949fa0b5e0c93a25 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 13:15:18 -0400 Subject: [PATCH 04/23] fix(api): update the logger details in merge-duplicate-user trigger job --- apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index d849208694..cfac59eaf8 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -228,7 +228,7 @@ export const mergeDuplicateUser = schemaTask({ logger.info('Re-pointed member relations', { policiesWithSignature: policiesWithSignature.length, trainingMigrated: toMigrate.length, - trainingDropped: newCompletions.length, + trainingDropped: existingCompletions.length - toMigrate.length, }); // ── Delete old member ──────────────────────────────────────────────── From 4ae255ca865c2da11533129414d4c06d7af44823 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 13:25:18 -0400 Subject: [PATCH 05/23] fix(api): add guard against normalized old/new domains in merge-org-email-domain --- apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts index 8b703fb7d9..70a5ec2c17 100644 --- a/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts +++ b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts @@ -16,6 +16,10 @@ export const migrateOrgEmailDomain = schemaTask({ const normalizedOldDomain = oldDomain.toLowerCase(); const normalizedNewDomain = newDomain.toLowerCase(); + if (normalizedOldDomain === normalizedNewDomain) { + logger.info('Old and new domains match after normalization', { organizationId, oldDomain, newDomain }); + return { mergedCount: 0, failedCount: 0, pairs: [] }; + } // Find all active members in the org with their user emails const members = await db.member.findMany({ From 6a647f3ee331eb8c6f13fdb3f7c2a47d0346c0a9 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 14:48:05 -0400 Subject: [PATCH 06/23] fix(api): re-point user-level relations before deleting old user in merge-duplicate-user --- .../tasks/people/merge-duplicate-user.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index cfac59eaf8..05c55a86a0 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -194,8 +194,8 @@ export const mergeDuplicateUser = schemaTask({ data: { memberId: n }, }); await tx.offboardingAccessRevocation.updateMany({ - where: { revokedById: o }, - data: { revokedById: n }, + where: { revokedById: oldMember.userId }, + data: { revokedById: newMember.userId }, }); // EmployeeTrainingVideoCompletion: skip videos the old member already has @@ -234,13 +234,53 @@ export const mergeDuplicateUser = schemaTask({ // ── Delete old member ──────────────────────────────────────────────── await tx.member.delete({ where: { id: o } }); - // ── Migrate new user's OAuth accounts to old user ──────────────────── + // ── Re-point user-level relations before deleting oldUser ─────────── + // Must happen before the delete to prevent cascade wiping these records. + + // OAuth accounts: move to surviving user await tx.account.updateMany({ where: { userId: oldUser.id }, data: { userId: newUser.id }, }); - // ── Delete new user sessions and the user record ───────────────────── + // AuditLog: onDelete Cascade — re-point to preserve history + await tx.auditLog.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + + // FleetPolicyResult: onDelete Cascade — re-point to preserve results + await tx.fleetPolicyResult.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + + // OauthAccessToken: onDelete Cascade + await tx.oauthAccessToken.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + + // OauthConsent: onDelete Cascade + await tx.oauthConsent.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + + // McpOrgBinding: onDelete Cascade, unique on userId — delete old, keep new + await tx.mcpOrgBinding.deleteMany({ where: { userId: oldUser.id } }); + + // IntegrationSyncLog / IntegrationOAuthError: nullable userId — re-point to preserve actor + await tx.integrationSyncLog.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + await tx.integrationOAuthError.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + + // ── Delete old user sessions and the user record ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); await tx.user.delete({ where: { id: oldUser.id } }); From e06d1a1a2287ff98dbc1e5b88f648ced1e9745c9 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 14:50:34 -0400 Subject: [PATCH 07/23] fix(api): migrate SOA and ISMS document approver before deleting old member --- .../src/trigger/tasks/people/merge-duplicate-user.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 05c55a86a0..da875b1fdb 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -231,6 +231,16 @@ export const mergeDuplicateUser = schemaTask({ trainingDropped: existingCompletions.length - toMigrate.length, }); + // SOADocument / IsmsDocument: approverId (SetNull on delete — re-point to preserve assignments) + await tx.sOADocument.updateMany({ + where: { approverId: o }, + data: { approverId: n }, + }); + await tx.ismsDocument.updateMany({ + where: { approverId: o }, + data: { approverId: n }, + }); + // ── Delete old member ──────────────────────────────────────────────── await tx.member.delete({ where: { id: o } }); From bba164efc3d3251287fe91f44a70d3fa869dac0c Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 15:05:53 -0400 Subject: [PATCH 08/23] fix(api): above user deletion inn merge-duplicate-user --- apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index da875b1fdb..7d00ba4e33 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -290,9 +290,8 @@ export const mergeDuplicateUser = schemaTask({ data: { userId: newUser.id }, }); - // ── Delete old user sessions and the user record ───────────────────── + // ── Delete old user sessions. ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); - await tx.user.delete({ where: { id: oldUser.id } }); // ── Update pending invitations ─────────────────────────────────────── await tx.invitation.updateMany({ From 7f30f838dff59990edf73703c0660384945be3ab Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 15:12:18 -0400 Subject: [PATCH 09/23] fix(api): dedupe offboarding and background check records before member merge --- .../tasks/people/merge-duplicate-user.ts | 90 +++++++++++++++---- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 7d00ba4e33..122cba784c 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -160,11 +160,19 @@ export const mergeDuplicateUser = schemaTask({ data: { memberId: n }, }); - // BackgroundCheckRequest: memberId - await tx.backgroundCheckRequest.updateMany({ - where: { memberId: o }, - data: { memberId: n }, - }); + // BackgroundCheckRequest: unique (organizationId, memberId) — delete old if new member already has one + const newBgCheck = await tx.backgroundCheckRequest.findUnique({ + where: { organizationId_memberId: { organizationId, memberId: n } }, + select: { id: true }, + }); + if (newBgCheck) { + await tx.backgroundCheckRequest.deleteMany({ where: { memberId: o } }); + } else { + await tx.backgroundCheckRequest.updateMany({ + where: { memberId: o }, + data: { memberId: n }, + }); + } // TrustAccessRequest: reviewerMemberId await tx.trustAccessRequest.updateMany({ @@ -182,17 +190,69 @@ export const mergeDuplicateUser = schemaTask({ data: { revokedByMemberId: n }, }); - // OffboardingChecklistCompletion: memberId - await tx.offboardingChecklistCompletion.updateMany({ - where: { memberId: o }, - data: { memberId: n }, - }); + // OffboardingChecklistCompletion: unique (memberId, templateItemId) — skip items new member already has + const existingChecklistCompletions = + await tx.offboardingChecklistCompletion.findMany({ + where: { memberId: o }, + select: { id: true, templateItemId: true }, + }); + const newChecklistTemplateItemIds = new Set( + ( + await tx.offboardingChecklistCompletion.findMany({ + where: { memberId: n }, + select: { templateItemId: true }, + }) + ).map((c) => c.templateItemId), + ); + const checklistToMigrate = existingChecklistCompletions.filter( + (c) => !newChecklistTemplateItemIds.has(c.templateItemId), + ); + const checklistToDrop = existingChecklistCompletions.filter((c) => + newChecklistTemplateItemIds.has(c.templateItemId), + ); + if (checklistToMigrate.length > 0) { + await tx.offboardingChecklistCompletion.updateMany({ + where: { id: { in: checklistToMigrate.map((c) => c.id) } }, + data: { memberId: n }, + }); + } + if (checklistToDrop.length > 0) { + await tx.offboardingChecklistCompletion.deleteMany({ + where: { id: { in: checklistToDrop.map((c) => c.id) } }, + }); + } - // OffboardingAccessRevocation: memberId, revokedById - await tx.offboardingAccessRevocation.updateMany({ - where: { memberId: o }, - data: { memberId: n }, - }); + // OffboardingAccessRevocation: unique (memberId, vendorId) — skip vendors new member already has + const existingRevocations = + await tx.offboardingAccessRevocation.findMany({ + where: { memberId: o }, + select: { id: true, vendorId: true }, + }); + const newRevocationVendorIds = new Set( + ( + await tx.offboardingAccessRevocation.findMany({ + where: { memberId: n }, + select: { vendorId: true }, + }) + ).map((r) => r.vendorId), + ); + const revocationsToMigrate = existingRevocations.filter( + (r) => !newRevocationVendorIds.has(r.vendorId), + ); + const revocationsToDrop = existingRevocations.filter((r) => + newRevocationVendorIds.has(r.vendorId), + ); + if (revocationsToMigrate.length > 0) { + await tx.offboardingAccessRevocation.updateMany({ + where: { id: { in: revocationsToMigrate.map((r) => r.id) } }, + data: { memberId: n }, + }); + } + if (revocationsToDrop.length > 0) { + await tx.offboardingAccessRevocation.deleteMany({ + where: { id: { in: revocationsToDrop.map((r) => r.id) } }, + }); + } await tx.offboardingAccessRevocation.updateMany({ where: { revokedById: oldMember.userId }, data: { revokedById: newMember.userId }, From fc471520800f87be37d7eac229ceabb57fd3c9eb Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 30 Jun 2026 15:33:49 -0400 Subject: [PATCH 10/23] fix(api): remove user after merging in merge-duplicate-user --- apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 122cba784c..06d9edf8a3 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -350,8 +350,9 @@ export const mergeDuplicateUser = schemaTask({ data: { userId: newUser.id }, }); - // ── Delete old user sessions. ───────────────────── + // ── Delete old user sessions and the user record ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); + await tx.user.delete({ where: { id: oldUser.id } }); // ── Update pending invitations ─────────────────────────────────────── await tx.invitation.updateMany({ From 39d0d8990796c3c69c11c66d17c44fa2dabb7a55 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 08:06:49 -0400 Subject: [PATCH 11/23] fix(api): avoid deleting user record in merge-duplicate-user --- apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 06d9edf8a3..e449161818 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -350,9 +350,8 @@ export const mergeDuplicateUser = schemaTask({ data: { userId: newUser.id }, }); - // ── Delete old user sessions and the user record ───────────────────── + // ── Delete old user sessions ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); - await tx.user.delete({ where: { id: oldUser.id } }); // ── Update pending invitations ─────────────────────────────────────── await tx.invitation.updateMany({ From b1faa1827d90ce1f87f956da21b72b8d0110a46d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 12:41:41 -0400 Subject: [PATCH 12/23] fix(api): skip user-level merge for old users in multiple orgs --- .../tasks/people/merge-duplicate-user.ts | 107 +++++++++++------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index e449161818..6e34ec5af5 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -54,6 +54,22 @@ export const mergeDuplicateUser = schemaTask({ newMemberId: newMember.id, }); + // ── 2b. Determine whether the old user belongs to other orgs ───────────── + // User-level relations (Account, sessions, etc.) are not org-scoped, so + // they can only be safely re-pointed/deleted if this is the old user's + // only org membership. Otherwise, only merge the member record for this + // org and leave the user record intact for their other orgs. + const otherOrgMemberships = await db.member.count({ + where: { userId: oldUser.id, organizationId: { not: organizationId } }, + }); + const oldUserHasOtherOrgs = otherOrgMemberships > 0; + + logger.info('Checked old user org memberships', { + oldUserId: oldUser.id, + otherOrgMemberships, + oldUserHasOtherOrgs, + }); + // ── 3. Merge inside a transaction ──────────────────────────────────────── await db.$transaction( @@ -304,54 +320,63 @@ export const mergeDuplicateUser = schemaTask({ // ── Delete old member ──────────────────────────────────────────────── await tx.member.delete({ where: { id: o } }); - // ── Re-point user-level relations before deleting oldUser ─────────── - // Must happen before the delete to prevent cascade wiping these records. + // ── User-level relations & old user record ─────────────────────────── + // Only safe when the old user has no membership in any other org — + // otherwise these relations (and the user record itself) still belong + // to that other org and must be left alone. + if (!oldUserHasOtherOrgs) { + // ── Re-point user-level relations before deleting oldUser ─────────── + // Must happen before the delete to prevent cascade wiping these records. + + // OAuth accounts: move to surviving user + await tx.account.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); - // OAuth accounts: move to surviving user - await tx.account.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); + // AuditLog: onDelete Cascade — re-point to preserve history + await tx.auditLog.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); - // AuditLog: onDelete Cascade — re-point to preserve history - await tx.auditLog.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); + // FleetPolicyResult: onDelete Cascade — re-point to preserve results + await tx.fleetPolicyResult.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); - // FleetPolicyResult: onDelete Cascade — re-point to preserve results - await tx.fleetPolicyResult.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); + // OauthAccessToken: onDelete Cascade + await tx.oauthAccessToken.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); - // OauthAccessToken: onDelete Cascade - await tx.oauthAccessToken.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); + // OauthConsent: onDelete Cascade + await tx.oauthConsent.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); - // OauthConsent: onDelete Cascade - await tx.oauthConsent.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); + // McpOrgBinding: onDelete Cascade, unique on userId — delete old, keep new + await tx.mcpOrgBinding.deleteMany({ where: { userId: oldUser.id } }); - // McpOrgBinding: onDelete Cascade, unique on userId — delete old, keep new - await tx.mcpOrgBinding.deleteMany({ where: { userId: oldUser.id } }); + // IntegrationSyncLog / IntegrationOAuthError: nullable userId — re-point to preserve actor + await tx.integrationSyncLog.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); + await tx.integrationOAuthError.updateMany({ + where: { userId: oldUser.id }, + data: { userId: newUser.id }, + }); - // IntegrationSyncLog / IntegrationOAuthError: nullable userId — re-point to preserve actor - await tx.integrationSyncLog.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); - await tx.integrationOAuthError.updateMany({ - where: { userId: oldUser.id }, - data: { userId: newUser.id }, - }); + // ── Delete old user sessions ───────────────────── + await tx.session.deleteMany({ where: { userId: oldUser.id } }); - // ── Delete old user sessions ───────────────────── - await tx.session.deleteMany({ where: { userId: oldUser.id } }); + // ── Delete the now-orphaned old user record ────────────────────── + await tx.user.delete({ where: { id: oldUser.id } }); + } // ── Update pending invitations ─────────────────────────────────────── await tx.invitation.updateMany({ @@ -368,12 +393,14 @@ export const mergeDuplicateUser = schemaTask({ newEmail, survivingUserId: oldUser.id, survivingMemberId: oldMember.id, + oldUserDeleted: !oldUserHasOtherOrgs, }); return { success: true, survivingUserId: newUser.id, survivingMemberId: newMember.id, + oldUserDeleted: !oldUserHasOtherOrgs, mergedUserId: oldUser.id, mergedMemberId: oldMember.id, }; From f8e0cc89193ce17081da2cb5ee40db33472dcd5b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 12:49:56 -0400 Subject: [PATCH 13/23] fix(api): re-point remaining user-level relations in merge-duplicate-user --- .../tasks/people/merge-duplicate-user.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 6e34ec5af5..7194a1a3f9 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -371,6 +371,34 @@ export const mergeDuplicateUser = schemaTask({ data: { userId: newUser.id }, }); + // EvidenceSubmission: onDelete SetNull — re-point to preserve authorship + await tx.evidenceSubmission.updateMany({ + where: { submittedById: oldUser.id }, + data: { submittedById: newUser.id }, + }); + await tx.evidenceSubmission.updateMany({ + where: { reviewedById: oldUser.id }, + data: { reviewedById: newUser.id }, + }); + + // Finding: createdByAdminId — re-point to preserve authorship + await tx.finding.updateMany({ + where: { createdByAdminId: oldUser.id }, + data: { createdByAdminId: newUser.id }, + }); + + // IntegrationResult: onDelete Cascade — re-point to preserve assignment + await tx.integrationResult.updateMany({ + where: { assignedUserId: oldUser.id }, + data: { assignedUserId: newUser.id }, + }); + + // OffboardingChecklistCompletion: onDelete SetNull — re-point to preserve actor + await tx.offboardingChecklistCompletion.updateMany({ + where: { completedById: oldUser.id }, + data: { completedById: newUser.id }, + }); + // ── Delete old user sessions ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); From 85c134d6a8531b3e73080bdad9a1a9ec79fa7113 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 12:52:15 -0400 Subject: [PATCH 14/23] fix(api): correct surviving user/member ids in merge-duplicate-user log --- apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 7194a1a3f9..d77628667c 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -419,8 +419,8 @@ export const mergeDuplicateUser = schemaTask({ organizationId, oldEmail, newEmail, - survivingUserId: oldUser.id, - survivingMemberId: oldMember.id, + survivingUserId: newUser.id, + survivingMemberId: newMember.id, oldUserDeleted: !oldUserHasOtherOrgs, }); From 5f2825ecda94a726342f895d6736f3416c0351b1 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 12:59:56 -0400 Subject: [PATCH 15/23] fix(api): clean up stale comments in merge-duplicate-user --- apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index d77628667c..2d9b1e3e79 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -323,11 +323,10 @@ export const mergeDuplicateUser = schemaTask({ // ── User-level relations & old user record ─────────────────────────── // Only safe when the old user has no membership in any other org — // otherwise these relations (and the user record itself) still belong - // to that other org and must be left alone. + // to that other org and must be left alone. Everything below must + // run before the tx.user.delete call at the end of this block, since + // some of these relations cascade-delete when their user is removed. if (!oldUserHasOtherOrgs) { - // ── Re-point user-level relations before deleting oldUser ─────────── - // Must happen before the delete to prevent cascade wiping these records. - // OAuth accounts: move to surviving user await tx.account.updateMany({ where: { userId: oldUser.id }, From c56dd323e96ea3a92a8fb7f000be9f5419c88cd0 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 2 Jul 2026 10:16:55 -0400 Subject: [PATCH 16/23] fix(api): remove scripts from .gitignore and make seeded DB test for email domain migration test --- apps/api/.gitignore | 3 - .../seed-email-domain-migration-test-data.ts | 162 ++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/scripts/seed-email-domain-migration-test-data.ts diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 8ba6979bb4..c46ddf5c6b 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -47,9 +47,6 @@ lerna-debug.log* .env.production.local .env.local -# Local scripts -scripts/ - # temp directory .temp .tmp diff --git a/apps/api/src/scripts/seed-email-domain-migration-test-data.ts b/apps/api/src/scripts/seed-email-domain-migration-test-data.ts new file mode 100644 index 0000000000..db1653897f --- /dev/null +++ b/apps/api/src/scripts/seed-email-domain-migration-test-data.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun +/** + * Seed a small test organization for exercising migrate-org-email-domain + * (and the merge-duplicate-user task it triggers for each matched pair). + * + * Usage: + * bun run apps/api/src/scripts/seed-email-domain-migration-test-data.ts + * + * Idempotent: re-running upserts the same org/users by slug/email instead of + * creating duplicates, so you can reseed after a merge run to reset state. + * + * Creates one org with member/email pairs covering: + * - alice: a clean pair with no other data attached + * - bob: a pair where the old member has a task + comment to re-point + * - carol: old-domain only, no new-domain counterpart (should be skipped) + * - dave: new-domain only, no old-domain counterpart (unaffected) + * - frank: old-domain email in a different case (tests normalization) + * - erin: old user is also a member of a second org (tests the + * merge-duplicate-user guard that keeps the User row when it + * belongs to more than one org) + */ + +import { db } from '@db'; + +const ORG_SLUG = 'email-migration-test-org'; +const OTHER_ORG_SLUG = 'email-migration-test-other-org'; +const OLD_DOMAIN = 'oldcorp.test'; +const NEW_DOMAIN = 'newcorp.test'; + +async function upsertOrg(slug: string, name: string) { + return db.organization.upsert({ + where: { slug }, + create: { slug, name, onboardingCompleted: true, hasAccess: true }, + update: { name }, + }); +} + +async function upsertUser(email: string, name: string) { + return db.user.upsert({ + where: { email }, + create: { email, name, emailVerified: true }, + update: { name }, + }); +} + +async function upsertMember({ + organizationId, + userId, + role = 'employee', +}: { + organizationId: string; + userId: string; + role?: string; +}) { + const existing = await db.member.findFirst({ + where: { organizationId, userId }, + }); + if (existing) return existing; + return db.member.create({ data: { organizationId, userId, role } }); +} + +async function main() { + console.log('Seeding email-domain-migration test data...\n'); + + const org = await upsertOrg(ORG_SLUG, 'Email Migration Test Org'); + const otherOrg = await upsertOrg( + OTHER_ORG_SLUG, + 'Email Migration Test Other Org', + ); + + // 1. Clean pair — merges with no other data attached. + const alice = { + old: await upsertUser(`alice@${OLD_DOMAIN}`, 'Alice Old'), + new: await upsertUser(`alice@${NEW_DOMAIN}`, 'Alice New'), + }; + await upsertMember({ organizationId: org.id, userId: alice.old.id }); + await upsertMember({ organizationId: org.id, userId: alice.new.id }); + + // 2. Pair where the old member owns real data that should get re-pointed. + const bob = { + old: await upsertUser(`bob@${OLD_DOMAIN}`, 'Bob Old'), + new: await upsertUser(`bob@${NEW_DOMAIN}`, 'Bob New'), + }; + const bobOldMember = await upsertMember({ + organizationId: org.id, + userId: bob.old.id, + }); + await upsertMember({ organizationId: org.id, userId: bob.new.id }); + + const bobTask = await db.task.create({ + data: { + organizationId: org.id, + title: 'Review vendor security questionnaire', + description: + 'Seeded task assigned to the old member — should re-point to the new member after merge.', + assigneeId: bobOldMember.id, + }, + }); + await db.comment.create({ + data: { + organizationId: org.id, + authorId: bobOldMember.id, + entityId: bobTask.id, + entityType: 'task', + content: 'Seeded comment authored by the old member.', + }, + }); + + // 3. Old-domain only — no new-domain counterpart, so the migration must skip it. + const carolOld = await upsertUser(`carol@${OLD_DOMAIN}`, 'Carol NoMatch'); + await upsertMember({ organizationId: org.id, userId: carolOld.id }); + + // 4. New-domain only — already migrated, unaffected either way. + const daveNew = await upsertUser(`dave@${NEW_DOMAIN}`, 'Dave New'); + await upsertMember({ organizationId: org.id, userId: daveNew.id }); + + // 5. Case-insensitive match: uppercase old-domain email vs. lowercase new-domain email. + const frank = { + old: await upsertUser(`FRANK@${OLD_DOMAIN.toUpperCase()}`, 'Frank Old'), + new: await upsertUser(`frank@${NEW_DOMAIN}`, 'Frank New'), + }; + await upsertMember({ organizationId: org.id, userId: frank.old.id }); + await upsertMember({ organizationId: org.id, userId: frank.new.id }); + + // 6. Old user also belongs to a second org — the User row must survive the merge. + const erin = { + old: await upsertUser(`erin@${OLD_DOMAIN}`, 'Erin Old'), + new: await upsertUser(`erin@${NEW_DOMAIN}`, 'Erin New'), + }; + await upsertMember({ organizationId: org.id, userId: erin.old.id }); + await upsertMember({ organizationId: org.id, userId: erin.new.id }); + await upsertMember({ organizationId: otherOrg.id, userId: erin.old.id }); + + console.log('Seed complete.\n'); + console.log(`organizationId: ${org.id}`); + console.log(`otherOrganizationId: ${otherOrg.id} (holds erin's other membership)`); + console.log(`oldDomain: ${OLD_DOMAIN}`); + console.log(`newDomain: ${NEW_DOMAIN}`); + + console.log('\nExpected to merge:'); + console.log(` alice@${OLD_DOMAIN} -> alice@${NEW_DOMAIN} (clean merge)`); + console.log(` bob@${OLD_DOMAIN} -> bob@${NEW_DOMAIN} (task + comment re-pointed)`); + console.log(` frank@${OLD_DOMAIN.toLowerCase()} -> frank@${NEW_DOMAIN} (case-insensitive match)`); + console.log(` erin@${OLD_DOMAIN} -> erin@${NEW_DOMAIN} (old user's account survives — still in ${OTHER_ORG_SLUG})`); + + console.log('\nExpected to skip:'); + console.log(` carol@${OLD_DOMAIN} (no new-domain counterpart)`); + console.log(` dave@${NEW_DOMAIN} (no old-domain counterpart)`); + + console.log('\nTrigger the migration with:'); + console.log( + ` migrateOrgEmailDomain.trigger({ organizationId: '${org.id}', oldDomain: '${OLD_DOMAIN}', newDomain: '${NEW_DOMAIN}' })`, + ); + + await db.$disconnect(); +} + +main().catch(async (error) => { + console.error('Seeding failed:', error); + await db.$disconnect(); + process.exit(1); +}); From 95c57793227a8dae033fe902b733200340d5b1ac Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 2 Jul 2026 10:36:49 -0400 Subject: [PATCH 17/23] fix(api): put local scripts to gitignore --- apps/api/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/.gitignore b/apps/api/.gitignore index c46ddf5c6b..8ba6979bb4 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -47,6 +47,9 @@ lerna-debug.log* .env.production.local .env.local +# Local scripts +scripts/ + # temp directory .temp .tmp From 2a45f2f1817268a0d5c6bc11b7c095bf5429ae47 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 2 Jul 2026 11:59:16 -0400 Subject: [PATCH 18/23] fix(api): write jest tests for email domain migration scripts --- .../merge-duplicate-user-db-mock.util.ts | 56 ++++ .../merge-duplicate-user-relations.spec.ts | 266 ++++++++++++++++++ .../tasks/people/merge-duplicate-user.spec.ts | 242 ++++++++++++++++ .../people/migrate-org-email-domain.spec.ts | 169 +++++++++++ 4 files changed, 733 insertions(+) create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user-db-mock.util.ts create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts create mode 100644 apps/api/src/trigger/tasks/people/migrate-org-email-domain.spec.ts diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-db-mock.util.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-db-mock.util.ts new file mode 100644 index 0000000000..79bca67277 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-db-mock.util.ts @@ -0,0 +1,56 @@ +export type ModelMock = Record; + +const MODEL_METHOD_DEFAULTS: Record = { + findMany: [], + findUnique: null, + findFirst: null, + updateMany: { count: 0 }, + deleteMany: { count: 0 }, + count: 0, +}; + +function createModelMock(): ModelMock { + const methods: ModelMock = {}; + return new Proxy(methods, { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + if (!target[prop]) { + const hasDefault = Object.prototype.hasOwnProperty.call( + MODEL_METHOD_DEFAULTS, + prop, + ); + target[prop] = jest + .fn() + .mockResolvedValue(hasDefault ? MODEL_METHOD_DEFAULTS[prop] : {}); + } + return target[prop]; + }, + }); +} + +/** + * Builds a `db`-shaped Proxy for tests: any model/method is auto-mocked on + * first access with a harmless default (empty lists / zero counts) unless a + * test overrides it. `$transaction` invokes its callback with the same + * proxy as `tx`, so assertions against the returned object see calls made + * inside a transaction too. Used from a `jest.mock('@db', ...)` factory via + * `require`, since factories can't close over module-scope variables. + */ +export function createDbProxyMock(): Record { + const models: Record = {}; + let dbProxy: Record; + const dbMock = { + $transaction: jest.fn( + async (fn: (tx: unknown) => Promise) => fn(dbProxy), + ), + }; + dbProxy = new Proxy(dbMock, { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + if (prop in target) return target[prop as keyof typeof target]; + if (!models[prop]) models[prop] = createModelMock(); + return models[prop]; + }, + }); + return dbProxy; +} diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts new file mode 100644 index 0000000000..54a4cc7b1c --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts @@ -0,0 +1,266 @@ +import { db } from '@db'; +import { mergeDuplicateUser } from './merge-duplicate-user'; + +// Companion to merge-duplicate-user.spec.ts: field-by-field coverage of every +// relation re-pointed by the merge, plus the dedup branches (unique +// constraints where a duplicate must be dropped instead of migrated). Split +// out to keep each spec file under the project's 300-line limit. + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schemaTask: (config: unknown) => config, + tags: { add: jest.fn() }, +})); + +jest.mock('@db', () => { + const { createDbProxyMock } = require('./merge-duplicate-user-db-mock.util'); + return { db: createDbProxyMock() }; +}); + +interface MergeDuplicateUserRunnable { + run: (params: { + organizationId: string; + oldEmail: string; + newEmail: string; + }) => Promise<{ success: boolean }>; +} + +const runMerge = (params: { + organizationId: string; + oldEmail: string; + newEmail: string; +}) => (mergeDuplicateUser as unknown as MergeDuplicateUserRunnable).run(params); + +const ORG_ID = 'org_1'; +const OLD_EMAIL = 'old@example.com'; +const NEW_EMAIL = 'new@example.com'; + +const oldUser = { id: 'usr_old', email: OLD_EMAIL }; +const newUser = { id: 'usr_new', email: NEW_EMAIL }; +const oldMember = { id: 'mem_old', userId: 'usr_old' }; +const newMember = { id: 'mem_new', userId: 'usr_new' }; + +describe('mergeDuplicateUser relation re-pointing', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (db.user.findUnique as jest.Mock).mockImplementation( + ({ where }: { where: { email: string } }) => { + if (where.email === OLD_EMAIL) return oldUser; + if (where.email === NEW_EMAIL) return newUser; + return null; + }, + ); + (db.member.findFirst as jest.Mock).mockImplementation( + ({ where }: { where: { userId: string } }) => { + if (where.userId === oldUser.id) return oldMember; + if (where.userId === newUser.id) return newMember; + return null; + }, + ); + (db.member.count as jest.Mock).mockResolvedValue(0); + }); + + it('re-points every other simple member-scoped relation', async () => { + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + const o = 'mem_old'; + const n = 'mem_new'; + expect(db.policy.updateMany).toHaveBeenCalledWith({ + where: { approverId: o }, + data: { approverId: n }, + }); + expect(db.policyVersion.updateMany).toHaveBeenCalledWith({ + where: { publishedById: o }, + data: { publishedById: n }, + }); + expect(db.risk.updateMany).toHaveBeenCalledWith({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + expect(db.vendor.updateMany).toHaveBeenCalledWith({ + where: { assigneeId: o }, + data: { assigneeId: n }, + }); + expect(db.finding.updateMany).toHaveBeenCalledWith({ + where: { memberId: o }, + data: { memberId: n }, + }); + expect(db.comment.updateMany).toHaveBeenCalledWith({ + where: { authorId: o }, + data: { authorId: n }, + }); + expect(db.device.updateMany).toHaveBeenCalledWith({ + where: { memberId: o }, + data: { memberId: n }, + }); + expect(db.trustAccessRequest.updateMany).toHaveBeenCalledWith({ + where: { reviewerMemberId: o }, + data: { reviewerMemberId: n }, + }); + expect(db.sOADocument.updateMany).toHaveBeenCalledWith({ + where: { approverId: o }, + data: { approverId: n }, + }); + expect(db.ismsDocument.updateMany).toHaveBeenCalledWith({ + where: { approverId: o }, + data: { approverId: n }, + }); + }); + + it('replaces the old member id inside Policy.signedBy arrays', async () => { + (db.policy.findMany as jest.Mock).mockResolvedValue([ + { id: 'pol_1', signedBy: ['mem_old', 'mem_other'] }, + ]); + + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.policy.update).toHaveBeenCalledWith({ + where: { id: 'pol_1' }, + data: { signedBy: ['mem_new', 'mem_other'] }, + }); + }); + + it('drops the old BackgroundCheckRequest when the new member already has one', async () => { + (db.backgroundCheckRequest.findUnique as jest.Mock).mockResolvedValue({ + id: 'bg_new', + }); + + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.backgroundCheckRequest.deleteMany).toHaveBeenCalledWith({ + where: { memberId: 'mem_old' }, + }); + expect(db.backgroundCheckRequest.updateMany).not.toHaveBeenCalled(); + }); + + it('migrates the old BackgroundCheckRequest when the new member has none', async () => { + (db.backgroundCheckRequest.findUnique as jest.Mock).mockResolvedValue(null); + + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.backgroundCheckRequest.updateMany).toHaveBeenCalledWith({ + where: { memberId: 'mem_old' }, + data: { memberId: 'mem_new' }, + }); + expect(db.backgroundCheckRequest.deleteMany).not.toHaveBeenCalled(); + }); + + it('migrates non-duplicate OffboardingChecklistCompletion rows and drops duplicates', async () => { + (db.offboardingChecklistCompletion.findMany as jest.Mock).mockImplementation( + ({ where }: { where: { memberId: string } }) => + where.memberId === 'mem_old' + ? [ + { id: 'occ_1', templateItemId: 'tpl_1' }, + { id: 'occ_2', templateItemId: 'tpl_2' }, + ] + : [{ templateItemId: 'tpl_2' }], + ); + + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.offboardingChecklistCompletion.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['occ_1'] } }, + data: { memberId: 'mem_new' }, + }); + expect(db.offboardingChecklistCompletion.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['occ_2'] } }, + }); + }); + + it('migrates non-duplicate OffboardingAccessRevocation rows, drops duplicates, and re-points revokedById', async () => { + (db.offboardingAccessRevocation.findMany as jest.Mock).mockImplementation( + ({ where }: { where: { memberId: string } }) => + where.memberId === 'mem_old' + ? [ + { id: 'oar_1', vendorId: 'vnd_1' }, + { id: 'oar_2', vendorId: 'vnd_2' }, + ] + : [{ vendorId: 'vnd_2' }], + ); + + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.offboardingAccessRevocation.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['oar_1'] } }, + data: { memberId: 'mem_new' }, + }); + expect(db.offboardingAccessRevocation.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['oar_2'] } }, + }); + expect(db.offboardingAccessRevocation.updateMany).toHaveBeenCalledWith({ + where: { revokedById: 'usr_old' }, + data: { revokedById: 'usr_new' }, + }); + }); + + it('migrates non-duplicate EmployeeTrainingVideoCompletion rows and leaves duplicates for cascade cleanup', async () => { + (db.employeeTrainingVideoCompletion.findMany as jest.Mock).mockImplementation( + ({ where }: { where: { memberId: string } }) => + where.memberId === 'mem_old' + ? [ + { id: 'evc_1', videoId: 'vid_1' }, + { id: 'evc_2', videoId: 'vid_2' }, + ] + : [{ videoId: 'vid_2' }], + ); + + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.employeeTrainingVideoCompletion.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['evc_1'] } }, + data: { memberId: 'mem_new' }, + }); + // No delete branch exists for this model — the duplicate row is left on + // the old member and is cleaned up by the member.delete cascade. + expect(db.employeeTrainingVideoCompletion.deleteMany).not.toHaveBeenCalled(); + }); + + it('re-points every other simple user-scoped relation', async () => { + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + const o = 'usr_old'; + const n = 'usr_new'; + expect(db.oauthAccessToken.updateMany).toHaveBeenCalledWith({ + where: { userId: o }, + data: { userId: n }, + }); + expect(db.oauthConsent.updateMany).toHaveBeenCalledWith({ + where: { userId: o }, + data: { userId: n }, + }); + expect(db.integrationSyncLog.updateMany).toHaveBeenCalledWith({ + where: { userId: o }, + data: { userId: n }, + }); + expect(db.integrationOAuthError.updateMany).toHaveBeenCalledWith({ + where: { userId: o }, + data: { userId: n }, + }); + expect(db.evidenceSubmission.updateMany).toHaveBeenCalledWith({ + where: { submittedById: o }, + data: { submittedById: n }, + }); + expect(db.evidenceSubmission.updateMany).toHaveBeenCalledWith({ + where: { reviewedById: o }, + data: { reviewedById: n }, + }); + expect(db.finding.updateMany).toHaveBeenCalledWith({ + where: { createdByAdminId: o }, + data: { createdByAdminId: n }, + }); + expect(db.offboardingChecklistCompletion.updateMany).toHaveBeenCalledWith({ + where: { completedById: o }, + data: { completedById: n }, + }); + }); + + it('deletes (not updates) the old McpOrgBinding, since userId is unique', async () => { + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.mcpOrgBinding.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'usr_old' }, + }); + expect(db.mcpOrgBinding.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts new file mode 100644 index 0000000000..86212cf34e --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts @@ -0,0 +1,242 @@ +import { db } from '@db'; +import { mergeDuplicateUser } from './merge-duplicate-user'; + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schemaTask: (config: unknown) => config, + tags: { add: jest.fn() }, +})); + +// Auto-mocking `db`/`tx`: the task touches ~30 models. Rather than hand-list +// every one, `createDbProxyMock` (in merge-duplicate-user-db-mock.util.ts) +// lazily builds a { updateMany, deleteMany, delete, findMany, findUnique, +// count } mock per model name on first access, resolving to harmless +// defaults unless a test overrides them. `$transaction` invokes its +// callback with the same proxy as `tx`, so assertions against +// `db..` see calls made via `tx` too. Pulled in via +// `require` (not a top-level import) because jest.mock factories can't +// close over module-scope bindings. +jest.mock('@db', () => { + const { createDbProxyMock } = require('./merge-duplicate-user-db-mock.util'); + return { db: createDbProxyMock() }; +}); + +// schemaTask's declared return type doesn't expose `run` as callable, but our +// mock above makes `schemaTask` return the config object verbatim — this +// narrows just enough to invoke it without an `any` escape hatch. +interface MergeDuplicateUserRunnable { + run: (params: { + organizationId: string; + oldEmail: string; + newEmail: string; + }) => Promise<{ + success: boolean; + survivingUserId: string; + survivingMemberId: string; + oldUserDeleted: boolean; + mergedUserId: string; + mergedMemberId: string; + }>; +} + +const runMerge = (params: { + organizationId: string; + oldEmail: string; + newEmail: string; +}) => (mergeDuplicateUser as unknown as MergeDuplicateUserRunnable).run(params); + +const ORG_ID = 'org_1'; +const OLD_EMAIL = 'old@example.com'; +const NEW_EMAIL = 'new@example.com'; + +const oldUser = { id: 'usr_old', email: OLD_EMAIL }; +const newUser = { id: 'usr_new', email: NEW_EMAIL }; +const oldMember = { id: 'mem_old', userId: 'usr_old' }; +const newMember = { id: 'mem_new', userId: 'usr_new' }; + +describe('mergeDuplicateUser', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (db.user.findUnique as jest.Mock).mockImplementation( + ({ where }: { where: { email: string } }) => { + if (where.email === OLD_EMAIL) return oldUser; + if (where.email === NEW_EMAIL) return newUser; + return null; + }, + ); + + (db.member.findFirst as jest.Mock).mockImplementation( + ({ where }: { where: { userId: string } }) => { + if (where.userId === oldUser.id) return oldMember; + if (where.userId === newUser.id) return newMember; + return null; + }, + ); + + // Default: the old user has no membership in any other org. + (db.member.count as jest.Mock).mockResolvedValue(0); + }); + + it('throws when the old user cannot be resolved by email', async () => { + (db.user.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + ).rejects.toThrow(`Old user not found: ${OLD_EMAIL}`); + }); + + it('throws when the old member cannot be resolved in this org', async () => { + (db.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + ).rejects.toThrow(/Old member not found/); + }); + + it('throws when the new user cannot be resolved by email', async () => { + (db.user.findUnique as jest.Mock).mockImplementation( + ({ where }: { where: { email: string } }) => + where.email === OLD_EMAIL ? oldUser : null, + ); + + await expect( + runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + ).rejects.toThrow(`New user not found: ${NEW_EMAIL}`); + }); + + it('throws when the new member cannot be resolved in this org', async () => { + (db.member.findFirst as jest.Mock).mockImplementation( + ({ where }: { where: { userId: string } }) => + where.userId === oldUser.id ? oldMember : null, + ); + + await expect( + runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + ).rejects.toThrow(/New member not found/); + }); + + describe('when the old user belongs to no other org', () => { + it('re-points member-level relations and deletes the old member', async () => { + const result = await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); + + expect(db.task.updateMany).toHaveBeenCalledWith({ + where: { assigneeId: 'mem_old' }, + data: { assigneeId: 'mem_new' }, + }); + expect(db.member.delete).toHaveBeenCalledWith({ where: { id: 'mem_old' } }); + expect(db.invitation.updateMany).toHaveBeenCalledWith({ + where: { email: OLD_EMAIL, organizationId: ORG_ID }, + data: { email: NEW_EMAIL }, + }); + expect(result.mergedMemberId).toBe('mem_old'); + expect(result.survivingMemberId).toBe('mem_new'); + }); + + it('re-points user-level relations and deletes the old user record', async () => { + const result = await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); + + expect(db.account.updateMany).toHaveBeenCalledWith({ + where: { userId: 'usr_old' }, + data: { userId: 'usr_new' }, + }); + expect(db.session.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'usr_old' }, + }); + expect(db.user.delete).toHaveBeenCalledWith({ where: { id: 'usr_old' } }); + expect(result.oldUserDeleted).toBe(true); + expect(result.mergedUserId).toBe('usr_old'); + expect(result.survivingUserId).toBe('usr_new'); + }); + + it('re-points cascade-vulnerable relations before deleting the old user, so nothing gets orphaned', async () => { + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + const userDeleteOrder = (db.user.delete as jest.Mock).mock + .invocationCallOrder[0]; + + // FleetPolicyResult and IntegrationResult cascade-delete on User removal; + // Session rows must also be cleared before the User row itself is gone. + for (const mock of [ + db.fleetPolicyResult.updateMany, + db.integrationResult.updateMany, + db.session.deleteMany, + ]) { + expect((mock as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + userDeleteOrder, + ); + } + }); + }); + + describe('when the old user is also a member of another org', () => { + beforeEach(() => { + (db.member.count as jest.Mock).mockResolvedValue(1); + }); + + it('skips the account move, session delete, and user delete', async () => { + const result = await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); + + expect(db.account.updateMany).not.toHaveBeenCalled(); + expect(db.session.deleteMany).not.toHaveBeenCalled(); + expect(db.user.delete).not.toHaveBeenCalled(); + expect(result.oldUserDeleted).toBe(false); + }); + + it('still merges member-level relations and deletes the old member for this org', async () => { + const result = await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); + + expect(db.member.delete).toHaveBeenCalledWith({ where: { id: 'mem_old' } }); + expect(db.invitation.updateMany).toHaveBeenCalledWith({ + where: { email: OLD_EMAIL, organizationId: ORG_ID }, + data: { email: NEW_EMAIL }, + }); + expect(result.mergedMemberId).toBe('mem_old'); + }); + + it('skips every other user-scoped relation, since the old user still belongs elsewhere', async () => { + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.fleetPolicyResult.updateMany).not.toHaveBeenCalled(); + expect(db.oauthAccessToken.updateMany).not.toHaveBeenCalled(); + expect(db.oauthConsent.updateMany).not.toHaveBeenCalled(); + expect(db.mcpOrgBinding.deleteMany).not.toHaveBeenCalled(); + expect(db.integrationSyncLog.updateMany).not.toHaveBeenCalled(); + expect(db.integrationOAuthError.updateMany).not.toHaveBeenCalled(); + expect(db.evidenceSubmission.updateMany).not.toHaveBeenCalled(); + expect(db.integrationResult.updateMany).not.toHaveBeenCalled(); + + // AuditLog and OffboardingChecklistCompletion are also touched at the + // member level (memberId), so assert the specific user-scoped calls + // never happened rather than asserting the mock overall. + expect(db.auditLog.updateMany).not.toHaveBeenCalledWith({ + where: { userId: 'usr_old' }, + data: { userId: 'usr_new' }, + }); + expect(db.finding.updateMany).not.toHaveBeenCalledWith({ + where: { createdByAdminId: 'usr_old' }, + data: { createdByAdminId: 'usr_new' }, + }); + expect(db.offboardingChecklistCompletion.updateMany).not.toHaveBeenCalledWith({ + where: { completedById: 'usr_old' }, + data: { completedById: 'usr_new' }, + }); + }); + }); +}); diff --git a/apps/api/src/trigger/tasks/people/migrate-org-email-domain.spec.ts b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.spec.ts new file mode 100644 index 0000000000..0c79aa3a81 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.spec.ts @@ -0,0 +1,169 @@ +import { db } from '@db'; +import { mergeDuplicateUser } from './merge-duplicate-user'; +import { migrateOrgEmailDomain } from './migrate-org-email-domain'; + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schemaTask: (config: unknown) => config, + tags: { add: jest.fn() }, +})); + +jest.mock('@db', () => ({ + db: { member: { findMany: jest.fn() } }, +})); + +jest.mock('./merge-duplicate-user', () => ({ + mergeDuplicateUser: { triggerAndWait: jest.fn() }, +})); + +// schemaTask's declared return type doesn't expose `run` as callable, but our +// mock above makes `schemaTask` return the config object verbatim — this +// narrows just enough to invoke it without an `any` escape hatch. +interface MigrateOrgEmailDomainRunnable { + run: (params: { + organizationId: string; + oldDomain: string; + newDomain: string; + }) => Promise<{ + mergedCount: number; + failedCount?: number; + pairs: Array<{ oldEmail: string; newEmail: string; ok: boolean; error?: string }>; + }>; +} + +const runMigration = (params: { + organizationId: string; + oldDomain: string; + newDomain: string; +}) => + (migrateOrgEmailDomain as unknown as MigrateOrgEmailDomainRunnable).run(params); + +const ORG_ID = 'org_1'; + +function member(email: string) { + return { user: { email } }; +} + +describe('migrateOrgEmailDomain', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mergeDuplicateUser.triggerAndWait as jest.Mock).mockResolvedValue({ ok: true }); + }); + + it('returns immediately when old and new domains normalize to the same value', async () => { + const result = await runMigration({ + organizationId: ORG_ID, + oldDomain: 'Example.com', + newDomain: 'example.COM', + }); + + expect(result).toEqual({ mergedCount: 0, failedCount: 0, pairs: [] }); + expect(db.member.findMany).not.toHaveBeenCalled(); + expect(mergeDuplicateUser.triggerAndWait).not.toHaveBeenCalled(); + }); + + it('queries only active, non-deactivated members of the org', async () => { + (db.member.findMany as jest.Mock).mockResolvedValue([]); + + await runMigration({ + organizationId: ORG_ID, + oldDomain: 'old.com', + newDomain: 'new.com', + }); + + expect(db.member.findMany).toHaveBeenCalledWith({ + where: { organizationId: ORG_ID, isActive: true, deactivated: false }, + select: { user: { select: { email: true } } }, + }); + }); + + it('finds no pairs when no old-domain email has a matching new-domain counterpart', async () => { + (db.member.findMany as jest.Mock).mockResolvedValue([ + member('carol@old.com'), + member('dave@new.com'), + ]); + + const result = await runMigration({ + organizationId: ORG_ID, + oldDomain: 'old.com', + newDomain: 'new.com', + }); + + expect(result).toEqual({ mergedCount: 0, pairs: [] }); + expect(mergeDuplicateUser.triggerAndWait).not.toHaveBeenCalled(); + }); + + it('matches old- and new-domain emails by local part, case-insensitively, preserving original casing', async () => { + (db.member.findMany as jest.Mock).mockResolvedValue([ + member('Alice@OLD.COM'), + member('alice@new.com'), + member('carol@old.com'), + member('dave@new.com'), + ]); + + await runMigration({ + organizationId: ORG_ID, + oldDomain: 'old.com', + newDomain: 'new.com', + }); + + expect(mergeDuplicateUser.triggerAndWait).toHaveBeenCalledTimes(1); + expect(mergeDuplicateUser.triggerAndWait).toHaveBeenCalledWith({ + organizationId: ORG_ID, + oldEmail: 'Alice@OLD.COM', + newEmail: 'alice@new.com', + }); + }); + + it('merges every matched pair and reports aggregate counts', async () => { + (db.member.findMany as jest.Mock).mockResolvedValue([ + member('alice@old.com'), + member('alice@new.com'), + member('bob@old.com'), + member('bob@new.com'), + ]); + (mergeDuplicateUser.triggerAndWait as jest.Mock) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }); + + const result = await runMigration({ + organizationId: ORG_ID, + oldDomain: 'old.com', + newDomain: 'new.com', + }); + + expect(mergeDuplicateUser.triggerAndWait).toHaveBeenCalledTimes(2); + expect(result.mergedCount).toBe(2); + expect(result.failedCount).toBe(0); + expect(result.pairs).toEqual([ + { oldEmail: 'alice@old.com', newEmail: 'alice@new.com', ok: true }, + { oldEmail: 'bob@old.com', newEmail: 'bob@new.com', ok: true }, + ]); + }); + + it('records a failed merge without aborting the remaining pairs', async () => { + (db.member.findMany as jest.Mock).mockResolvedValue([ + member('alice@old.com'), + member('alice@new.com'), + member('bob@old.com'), + member('bob@new.com'), + ]); + (mergeDuplicateUser.triggerAndWait as jest.Mock) + .mockResolvedValueOnce({ ok: false, error: 'boom' }) + .mockResolvedValueOnce({ ok: true }); + + const result = await runMigration({ + organizationId: ORG_ID, + oldDomain: 'old.com', + newDomain: 'new.com', + }); + + expect(mergeDuplicateUser.triggerAndWait).toHaveBeenCalledTimes(2); + expect(result.mergedCount).toBe(1); + expect(result.failedCount).toBe(1); + expect(result.pairs).toEqual([ + { oldEmail: 'alice@old.com', newEmail: 'alice@new.com', ok: false, error: 'boom' }, + { oldEmail: 'bob@old.com', newEmail: 'bob@new.com', ok: true }, + ]); + }); +}); From 21cad2d7d47773802304dd64a6dc3c94a3ade2b5 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 2 Jul 2026 13:47:35 -0400 Subject: [PATCH 19/23] fix(api): keep old user record instead of deleting in merge-duplicate-user --- .../tasks/people/merge-duplicate-user.spec.ts | 28 +++++-------------- .../tasks/people/merge-duplicate-user.ts | 17 +++++------ 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts index 86212cf34e..6c14f72f8b 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts @@ -33,7 +33,7 @@ interface MergeDuplicateUserRunnable { success: boolean; survivingUserId: string; survivingMemberId: string; - oldUserDeleted: boolean; + userLevelRelationsMerged: boolean; mergedUserId: string; mergedMemberId: string; }>; @@ -137,7 +137,7 @@ describe('mergeDuplicateUser', () => { expect(result.survivingMemberId).toBe('mem_new'); }); - it('re-points user-level relations and deletes the old user record', async () => { + it('re-points user-level relations and clears the old user sessions', async () => { const result = await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, @@ -151,29 +151,15 @@ describe('mergeDuplicateUser', () => { expect(db.session.deleteMany).toHaveBeenCalledWith({ where: { userId: 'usr_old' }, }); - expect(db.user.delete).toHaveBeenCalledWith({ where: { id: 'usr_old' } }); - expect(result.oldUserDeleted).toBe(true); + expect(result.userLevelRelationsMerged).toBe(true); expect(result.mergedUserId).toBe('usr_old'); expect(result.survivingUserId).toBe('usr_new'); }); - it('re-points cascade-vulnerable relations before deleting the old user, so nothing gets orphaned', async () => { + it('keeps the old user record instead of deleting it', async () => { await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); - const userDeleteOrder = (db.user.delete as jest.Mock).mock - .invocationCallOrder[0]; - - // FleetPolicyResult and IntegrationResult cascade-delete on User removal; - // Session rows must also be cleared before the User row itself is gone. - for (const mock of [ - db.fleetPolicyResult.updateMany, - db.integrationResult.updateMany, - db.session.deleteMany, - ]) { - expect((mock as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( - userDeleteOrder, - ); - } + expect(db.user.delete).not.toHaveBeenCalled(); }); }); @@ -182,7 +168,7 @@ describe('mergeDuplicateUser', () => { (db.member.count as jest.Mock).mockResolvedValue(1); }); - it('skips the account move, session delete, and user delete', async () => { + it('skips the account move and session delete', async () => { const result = await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, @@ -192,7 +178,7 @@ describe('mergeDuplicateUser', () => { expect(db.account.updateMany).not.toHaveBeenCalled(); expect(db.session.deleteMany).not.toHaveBeenCalled(); expect(db.user.delete).not.toHaveBeenCalled(); - expect(result.oldUserDeleted).toBe(false); + expect(result.userLevelRelationsMerged).toBe(false); }); it('still merges member-level relations and deletes the old member for this org', async () => { diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 2d9b1e3e79..452741ddcd 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -320,12 +320,12 @@ export const mergeDuplicateUser = schemaTask({ // ── Delete old member ──────────────────────────────────────────────── await tx.member.delete({ where: { id: o } }); - // ── User-level relations & old user record ─────────────────────────── + // ── User-level relations ────────────────────────────────────────────── // Only safe when the old user has no membership in any other org — - // otherwise these relations (and the user record itself) still belong - // to that other org and must be left alone. Everything below must - // run before the tx.user.delete call at the end of this block, since - // some of these relations cascade-delete when their user is removed. + // otherwise these relations still belong to that other org and must + // be left alone. The old User record itself is kept (not deleted) so + // it remains addressable, but its sessions are cleared and every + // relation below moves to the surviving user. if (!oldUserHasOtherOrgs) { // OAuth accounts: move to surviving user await tx.account.updateMany({ @@ -400,9 +400,6 @@ export const mergeDuplicateUser = schemaTask({ // ── Delete old user sessions ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); - - // ── Delete the now-orphaned old user record ────────────────────── - await tx.user.delete({ where: { id: oldUser.id } }); } // ── Update pending invitations ─────────────────────────────────────── @@ -420,14 +417,14 @@ export const mergeDuplicateUser = schemaTask({ newEmail, survivingUserId: newUser.id, survivingMemberId: newMember.id, - oldUserDeleted: !oldUserHasOtherOrgs, + userLevelRelationsMerged: !oldUserHasOtherOrgs, }); return { success: true, survivingUserId: newUser.id, survivingMemberId: newMember.id, - oldUserDeleted: !oldUserHasOtherOrgs, + userLevelRelationsMerged: !oldUserHasOtherOrgs, mergedUserId: oldUser.id, mergedMemberId: oldMember.id, }; From f1dad218e297741dcb48611cff1cb48f92ce3bb3 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 2 Jul 2026 14:28:06 -0400 Subject: [PATCH 20/23] fix(api): scope OffboardingAccessRevocation.revokedById to the multi-org guard --- .../people/merge-duplicate-user-relations.spec.ts | 10 +++++----- .../trigger/tasks/people/merge-duplicate-user.spec.ts | 4 ++++ .../src/trigger/tasks/people/merge-duplicate-user.ts | 10 ++++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts index 54a4cc7b1c..5946ee127a 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts @@ -168,7 +168,7 @@ describe('mergeDuplicateUser relation re-pointing', () => { }); }); - it('migrates non-duplicate OffboardingAccessRevocation rows, drops duplicates, and re-points revokedById', async () => { + it('migrates non-duplicate OffboardingAccessRevocation rows and drops duplicates', async () => { (db.offboardingAccessRevocation.findMany as jest.Mock).mockImplementation( ({ where }: { where: { memberId: string } }) => where.memberId === 'mem_old' @@ -188,10 +188,6 @@ describe('mergeDuplicateUser relation re-pointing', () => { expect(db.offboardingAccessRevocation.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['oar_2'] } }, }); - expect(db.offboardingAccessRevocation.updateMany).toHaveBeenCalledWith({ - where: { revokedById: 'usr_old' }, - data: { revokedById: 'usr_new' }, - }); }); it('migrates non-duplicate EmployeeTrainingVideoCompletion rows and leaves duplicates for cascade cleanup', async () => { @@ -253,6 +249,10 @@ describe('mergeDuplicateUser relation re-pointing', () => { where: { completedById: o }, data: { completedById: n }, }); + expect(db.offboardingAccessRevocation.updateMany).toHaveBeenCalledWith({ + where: { revokedById: o }, + data: { revokedById: n }, + }); }); it('deletes (not updates) the old McpOrgBinding, since userId is unique', async () => { diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts index 6c14f72f8b..93fed1649d 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts @@ -223,6 +223,10 @@ describe('mergeDuplicateUser', () => { where: { completedById: 'usr_old' }, data: { completedById: 'usr_new' }, }); + expect(db.offboardingAccessRevocation.updateMany).not.toHaveBeenCalledWith({ + where: { revokedById: 'usr_old' }, + data: { revokedById: 'usr_new' }, + }); }); }); }); diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 452741ddcd..3976530bac 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -269,10 +269,6 @@ export const mergeDuplicateUser = schemaTask({ where: { id: { in: revocationsToDrop.map((r) => r.id) } }, }); } - await tx.offboardingAccessRevocation.updateMany({ - where: { revokedById: oldMember.userId }, - data: { revokedById: newMember.userId }, - }); // EmployeeTrainingVideoCompletion: skip videos the old member already has const existingCompletions = @@ -398,6 +394,12 @@ export const mergeDuplicateUser = schemaTask({ data: { completedById: newUser.id }, }); + // OffboardingAccessRevocation.revokedById: onDelete SetNull — re-point to preserve actor + await tx.offboardingAccessRevocation.updateMany({ + where: { revokedById: oldUser.id }, + data: { revokedById: newUser.id }, + }); + // ── Delete old user sessions ───────────────────── await tx.session.deleteMany({ where: { userId: oldUser.id } }); } From 3976d8441c2e445fdfce35e58fc803c3fcc3d4d9 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 3 Jul 2026 08:05:41 -0400 Subject: [PATCH 21/23] fix(api): re-point IsmsObjective.ownerMemberId in merge-duplicate-user --- .../tasks/people/merge-duplicate-user-relations.spec.ts | 4 ++++ apps/api/src/trigger/tasks/people/merge-duplicate-user.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts index 5946ee127a..f7c820b94f 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts @@ -106,6 +106,10 @@ describe('mergeDuplicateUser relation re-pointing', () => { where: { approverId: o }, data: { approverId: n }, }); + expect(db.ismsObjective.updateMany).toHaveBeenCalledWith({ + where: { ownerMemberId: o }, + data: { ownerMemberId: n }, + }); }); it('replaces the old member id inside Policy.signedBy arrays', async () => { diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 3976530bac..7f58b4898b 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -313,6 +313,12 @@ export const mergeDuplicateUser = schemaTask({ data: { approverId: n }, }); + // IsmsObjective: ownerMemberId (plain id, no FK — re-point to avoid dangling reference) + await tx.ismsObjective.updateMany({ + where: { ownerMemberId: o }, + data: { ownerMemberId: n }, + }); + // ── Delete old member ──────────────────────────────────────────────── await tx.member.delete({ where: { id: o } }); From 0297b43132e1e75afa65851fb039e8bcf92e021c Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 3 Jul 2026 09:14:22 -0400 Subject: [PATCH 22/23] fix(api): add inequality guard to merge-duplicate-user --- .../tasks/people/merge-duplicate-user.spec.ts | 41 +++++++++++++++++++ .../tasks/people/merge-duplicate-user.ts | 16 +++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts index 93fed1649d..39e6a834d7 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts @@ -39,12 +39,21 @@ interface MergeDuplicateUserRunnable { }>; } +interface MergeDuplicateUserSchema { + schema: { safeParse: (input: unknown) => { success: boolean } }; +} + const runMerge = (params: { organizationId: string; oldEmail: string; newEmail: string; }) => (mergeDuplicateUser as unknown as MergeDuplicateUserRunnable).run(params); +const parseInput = (input: unknown) => + (mergeDuplicateUser as unknown as MergeDuplicateUserSchema).schema.safeParse( + input, + ); + const ORG_ID = 'org_1'; const OLD_EMAIL = 'old@example.com'; const NEW_EMAIL = 'new@example.com'; @@ -78,6 +87,38 @@ describe('mergeDuplicateUser', () => { (db.member.count as jest.Mock).mockResolvedValue(0); }); + describe('schema validation', () => { + it('rejects when newEmail equals oldEmail', () => { + const result = parseInput({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: OLD_EMAIL, + }); + + expect(result.success).toBe(false); + }); + + it('rejects when newEmail equals oldEmail case-insensitively', () => { + const result = parseInput({ + organizationId: ORG_ID, + oldEmail: 'User@Example.com', + newEmail: 'user@example.com', + }); + + expect(result.success).toBe(false); + }); + + it('accepts when oldEmail and newEmail differ', () => { + const result = parseInput({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); + + expect(result.success).toBe(true); + }); + }); + it('throws when the old user cannot be resolved by email', async () => { (db.user.findUnique as jest.Mock).mockResolvedValue(null); diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index 7f58b4898b..aa78232cb6 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -4,11 +4,17 @@ import { z } from 'zod'; export const mergeDuplicateUser = schemaTask({ id: 'merge-duplicate-user', - schema: z.object({ - organizationId: z.string(), - oldEmail: z.string().email(), - newEmail: z.string().email(), - }), + schema: z + .object({ + organizationId: z.string(), + oldEmail: z.string().email(), + newEmail: z.string().email(), + }) + .refine( + ({ oldEmail, newEmail }) => + oldEmail.toLowerCase() !== newEmail.toLowerCase(), + { path: ['newEmail'], message: 'newEmail must differ from oldEmail' }, + ), run: async ({ organizationId, oldEmail, newEmail }) => { await tags.add([`org:${organizationId}`]); From f134a35bd07ab8e729d53fe521a7fef0a05b041c Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 3 Jul 2026 09:19:41 -0400 Subject: [PATCH 23/23] fix(api): recompute org-membership check inside merge-duplicate-user transaction --- .../tasks/people/merge-duplicate-user.ts | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts index aa78232cb6..a4f590be8c 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -60,29 +60,33 @@ export const mergeDuplicateUser = schemaTask({ newMemberId: newMember.id, }); - // ── 2b. Determine whether the old user belongs to other orgs ───────────── - // User-level relations (Account, sessions, etc.) are not org-scoped, so - // they can only be safely re-pointed/deleted if this is the old user's - // only org membership. Otherwise, only merge the member record for this - // org and leave the user record intact for their other orgs. - const otherOrgMemberships = await db.member.count({ - where: { userId: oldUser.id, organizationId: { not: organizationId } }, - }); - const oldUserHasOtherOrgs = otherOrgMemberships > 0; - - logger.info('Checked old user org memberships', { - oldUserId: oldUser.id, - otherOrgMemberships, - oldUserHasOtherOrgs, - }); - // ── 3. Merge inside a transaction ──────────────────────────────────────── + let oldUserHasOtherOrgs = false; + await db.$transaction( async (tx) => { const o = oldMember.id; const n = newMember.id; + // ── 2b. Determine whether the old user belongs to other orgs ───────── + // User-level relations (Account, sessions, etc.) are not org-scoped, so + // they can only be safely re-pointed/deleted if this is the old user's + // only org membership. Otherwise, only merge the member record for this + // org and leave the user record intact for their other orgs. Computed + // inside the transaction (not before it) so a concurrent membership + // change can't make this stale relative to the mutations below. + const otherOrgMemberships = await tx.member.count({ + where: { userId: oldUser.id, organizationId: { not: organizationId } }, + }); + oldUserHasOtherOrgs = otherOrgMemberships > 0; + + logger.info('Checked old user org memberships', { + oldUserId: oldUser.id, + otherOrgMemberships, + oldUserHasOtherOrgs, + }); + // Policies: assigneeId, approverId, signedBy (String[] — replace in array) await tx.policy.updateMany({ where: { assigneeId: o },