From 81582b1c5127e82c5dddb773dccbcb759c7e01c8 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 29 Jun 2026 17:21:21 -0400 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 }, From 4465ddb80845fee39effd97b2b884d27c1dc4238 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 22 Jul 2026 11:48:05 -0400 Subject: [PATCH 24/24] fix(api): discover member foreign keys from Postgres catalog instead of a hand-maintained list --- .../merge-duplicate-user-db-mock.util.ts | 12 +- .../merge-duplicate-user-fk-discovery.spec.ts | 186 +++++++++++++ .../merge-duplicate-user-fk-discovery.ts | 118 ++++++++ ...ge-duplicate-user-member-relations.spec.ts | 212 ++++++++++++++ .../merge-duplicate-user-member-relations.ts | 239 ++++++++++++++++ .../merge-duplicate-user-relations.spec.ts | 193 +++---------- .../tasks/people/merge-duplicate-user.spec.ts | 75 ++++- .../tasks/people/merge-duplicate-user.ts | 260 ++---------------- 8 files changed, 878 insertions(+), 417 deletions(-) create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.spec.ts create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.ts create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.spec.ts create mode 100644 apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.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 index 79bca67277..9a36c4fc26 100644 --- 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 @@ -40,9 +40,17 @@ export function createDbProxyMock(): Record { const models: Record = {}; let dbProxy: Record; const dbMock = { - $transaction: jest.fn( - async (fn: (tx: unknown) => Promise) => fn(dbProxy), + $transaction: jest.fn(async (fn: (tx: unknown) => Promise) => + fn(dbProxy), ), + // Raw-query methods (used for catalog-driven FK discovery — see + // merge-duplicate-user-fk-discovery.ts) are explicit callable stubs + // rather than falling into the generic "unknown prop -> model mock" + // branch below, which would produce a non-callable object instead of a + // jest.fn(). Default to harmless empty results; tests that need + // specific rows override these directly. + $queryRaw: jest.fn().mockResolvedValue([]), + $executeRaw: jest.fn().mockResolvedValue(0), }; dbProxy = new Proxy(dbMock, { get: (target, prop) => { diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.spec.ts new file mode 100644 index 0000000000..730879147b --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.spec.ts @@ -0,0 +1,186 @@ +// Prisma.sql/Prisma.raw are pure template-string builders — safe to use the +// real ones from @prisma/client without a DB connection. Mocked here only so +// importing '@db' (which re-exports the whole @prisma/client package plus +// the lazily-connecting `db` proxy) doesn't risk touching anything else. +jest.mock('@db', () => { + const { Prisma } = require('@prisma/client'); + return { Prisma }; +}); + +import { + assertNoDanglingMemberReferences, + findMemberForeignKeys, + quoteIdentifier, + repointGenericForeignKeys, +} from './merge-duplicate-user-fk-discovery'; + +function createFakeTx() { + return { + $queryRaw: jest.fn(), + $executeRaw: jest.fn(), + }; +} + +describe('quoteIdentifier', () => { + it('quotes a safe identifier', () => { + expect(quoteIdentifier('Task')).toBe('"Task"'); + expect(quoteIdentifier('background_check_requests')).toBe( + '"background_check_requests"', + ); + }); + + it('rejects identifiers with characters outside [A-Za-z0-9_]', () => { + expect(() => quoteIdentifier('Task"; DROP TABLE "Task')).toThrow( + /Refusing to use unexpected SQL identifier/, + ); + expect(() => quoteIdentifier('some table')).toThrow(); + expect(() => quoteIdentifier('')).toThrow(); + }); +}); + +describe('findMemberForeignKeys', () => { + it('maps information_schema rows to tableName/columnName pairs', async () => { + const tx = createFakeTx(); + tx.$queryRaw.mockResolvedValue([ + { table_name: 'Task', column_name: 'assigneeId' }, + { table_name: 'background_check_requests', column_name: 'memberId' }, + ]); + + const result = await findMemberForeignKeys(tx as never); + + expect(result).toEqual([ + { tableName: 'Task', columnName: 'assigneeId' }, + { tableName: 'background_check_requests', columnName: 'memberId' }, + ]); + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + }); + + it('returns an empty list when no foreign keys are found', async () => { + const tx = createFakeTx(); + tx.$queryRaw.mockResolvedValue([]); + + expect(await findMemberForeignKeys(tx as never)).toEqual([]); + }); +}); + +describe('repointGenericForeignKeys', () => { + it('runs an UPDATE for each foreign key not in skipTables', async () => { + const tx = createFakeTx(); + tx.$executeRaw.mockResolvedValue(1); + const foreignKeys = [ + { tableName: 'Task', columnName: 'assigneeId' }, + { tableName: 'Risk', columnName: 'assigneeId' }, + ]; + + const repointed = await repointGenericForeignKeys( + tx as never, + foreignKeys, + new Set(), + 'mem_old', + 'mem_new', + ); + + expect(tx.$executeRaw).toHaveBeenCalledTimes(2); + expect(repointed).toEqual(foreignKeys); + }); + + it('skips tables in skipTables entirely', async () => { + const tx = createFakeTx(); + tx.$executeRaw.mockResolvedValue(1); + const foreignKeys = [ + { tableName: 'Task', columnName: 'assigneeId' }, + { tableName: 'background_check_requests', columnName: 'memberId' }, + ]; + + const repointed = await repointGenericForeignKeys( + tx as never, + foreignKeys, + new Set(['background_check_requests']), + 'mem_old', + 'mem_new', + ); + + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + expect(repointed).toEqual([ + { tableName: 'Task', columnName: 'assigneeId' }, + ]); + }); + + it('returns an empty list and issues no updates when given no foreign keys', async () => { + const tx = createFakeTx(); + + const repointed = await repointGenericForeignKeys( + tx as never, + [], + new Set(), + 'mem_old', + 'mem_new', + ); + + expect(tx.$executeRaw).not.toHaveBeenCalled(); + expect(repointed).toEqual([]); + }); +}); + +describe('assertNoDanglingMemberReferences', () => { + it('resolves without throwing when nothing still references the old member', async () => { + const tx = createFakeTx(); + tx.$queryRaw.mockResolvedValue([{ exists: false }]); + + await expect( + assertNoDanglingMemberReferences( + tx as never, + [{ tableName: 'Task', columnName: 'assigneeId' }], + 'mem_old', + { + 'Extra.check': () => Promise.resolve(0), + }, + ), + ).resolves.toBeUndefined(); + }); + + it('throws naming the table.column still pointing at the old member', async () => { + const tx = createFakeTx(); + tx.$queryRaw.mockResolvedValue([{ exists: true }]); + + await expect( + assertNoDanglingMemberReferences( + tx as never, + [{ tableName: 'Task', columnName: 'assigneeId' }], + 'mem_old', + {}, + ), + ).rejects.toThrow('Task.assigneeId'); + }); + + it('throws naming an extra (non-FK) check that still has dangling rows', async () => { + const tx = createFakeTx(); + tx.$queryRaw.mockResolvedValue([{ exists: false }]); + + await expect( + assertNoDanglingMemberReferences(tx as never, [], 'mem_old', { + 'Policy.signedBy': () => Promise.resolve(2), + }), + ).rejects.toThrow('Policy.signedBy'); + }); + + it('aggregates every dangling reference into a single error', async () => { + const tx = createFakeTx(); + // Two foreign keys checked in order — both dangling. + tx.$queryRaw + .mockResolvedValueOnce([{ exists: true }]) + .mockResolvedValueOnce([{ exists: true }]); + + await expect( + assertNoDanglingMemberReferences( + tx as never, + [ + { tableName: 'Task', columnName: 'assigneeId' }, + { tableName: 'Risk', columnName: 'assigneeId' }, + ], + 'mem_old', + { 'Policy.signedBy': () => Promise.resolve(1) }, + ), + ).rejects.toThrow('Task.assigneeId, Risk.assigneeId, Policy.signedBy'); + }); +}); diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.ts new file mode 100644 index 0000000000..d9271f3912 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-fk-discovery.ts @@ -0,0 +1,118 @@ +import { Prisma } from '@db'; + +/** + * Reads foreign keys pointing at Member.id directly from Postgres's own + * catalog, instead of a hand-maintained list — so a relation added later is + * picked up automatically the next time the merge runs, rather than being + * silently missed (which is what happened previously: a relation was added + * while this merge task's PR was still open, the hand-written list never + * learned about it, and that relation's row for the old member was lost + * instead of re-pointed). + */ + +export interface MemberForeignKey { + tableName: string; + columnName: string; +} + +const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export function quoteIdentifier(identifier: string): string { + if (!SAFE_IDENTIFIER.test(identifier)) { + throw new Error( + `Refusing to use unexpected SQL identifier from information_schema: ${identifier}`, + ); + } + return `"${identifier}"`; +} + +/** Every foreign key in the current schema that references Member.id. */ +export async function findMemberForeignKeys( + tx: Prisma.TransactionClient, +): Promise { + const rows = await tx.$queryRaw< + Array<{ table_name: string; column_name: string }> + >` + SELECT tc.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = current_schema() + AND ccu.table_name = 'Member' + AND ccu.column_name = 'id' + `; + return rows.map((row) => ({ + tableName: row.table_name, + columnName: row.column_name, + })); +} + +/** + * Re-points every discovered foreign key from `oldMemberId` to + * `newMemberId`, except `skipTables` — tables whose member-id column shares + * a unique constraint with another field, where a blind `UPDATE` could + * violate that constraint. Returns the ones actually updated, for logging. + */ +export async function repointGenericForeignKeys( + tx: Prisma.TransactionClient, + foreignKeys: MemberForeignKey[], + skipTables: ReadonlySet, + oldMemberId: string, + newMemberId: string, +): Promise { + const repointed: MemberForeignKey[] = []; + for (const fk of foreignKeys) { + if (skipTables.has(fk.tableName)) continue; + const table = quoteIdentifier(fk.tableName); + const column = quoteIdentifier(fk.columnName); + await tx.$executeRaw( + Prisma.sql`UPDATE ${Prisma.raw(table)} SET ${Prisma.raw(column)} = ${newMemberId} WHERE ${Prisma.raw(column)} = ${oldMemberId}`, + ); + repointed.push(fk); + } + return repointed; +} + +/** + * Throws if any discovered foreign key still has a row pointing at + * `oldMemberId` — a safety net so an unhandled or buggy relation fails + * loudly instead of silently losing data when the old member row is deleted + * afterward (some relations cascade-delete or null out on that delete). + * `extraChecks` covers member-id columns with no real foreign key (so + * catalog introspection can never find them) — each returns the count of + * rows still referencing `oldMemberId`. + */ +export async function assertNoDanglingMemberReferences( + tx: Prisma.TransactionClient, + foreignKeys: MemberForeignKey[], + oldMemberId: string, + extraChecks: Record Promise>, +): Promise { + const dangling: string[] = []; + + for (const fk of foreignKeys) { + const table = quoteIdentifier(fk.tableName); + const column = quoteIdentifier(fk.columnName); + const rows = await tx.$queryRaw>( + Prisma.sql`SELECT EXISTS(SELECT 1 FROM ${Prisma.raw(table)} WHERE ${Prisma.raw(column)} = ${oldMemberId}) AS exists`, + ); + if (rows[0]?.exists) { + dangling.push(`${fk.tableName}.${fk.columnName}`); + } + } + + for (const [label, check] of Object.entries(extraChecks)) { + if ((await check()) > 0) dangling.push(label); + } + + if (dangling.length > 0) { + throw new Error( + `Merge safety check failed: member ${oldMemberId} is still referenced by: ${dangling.join(', ')}`, + ); + } +} diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.spec.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.spec.ts new file mode 100644 index 0000000000..fb1207320f --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.spec.ts @@ -0,0 +1,212 @@ +// The catalog-driven discovery mechanism itself is tested in isolation in +// merge-duplicate-user-fk-discovery.spec.ts. Mocked here so this file only +// covers repointMemberRelations's own concerns: wiring discovery + generic +// repoint + the hand-written exceptions (unique-constraint dedupe, +// Policy.signedBy, IsmsObjective.ownerMemberId) + the final safety check. +const mockFindMemberForeignKeys = jest.fn(); +const mockRepointGenericForeignKeys = jest.fn(); +const mockAssertNoDanglingMemberReferences = jest.fn(); +jest.mock('./merge-duplicate-user-fk-discovery', () => ({ + findMemberForeignKeys: (...args: unknown[]) => + mockFindMemberForeignKeys(...args), + repointGenericForeignKeys: (...args: unknown[]) => + mockRepointGenericForeignKeys(...args), + assertNoDanglingMemberReferences: (...args: unknown[]) => + mockAssertNoDanglingMemberReferences(...args), +})); + +jest.mock('@db', () => { + const { createDbProxyMock } = require('./merge-duplicate-user-db-mock.util'); + return { db: createDbProxyMock(), Prisma: {} }; +}); + +import { db } from '@db'; +import { repointMemberRelations } from './merge-duplicate-user-member-relations'; + +const ORG_ID = 'org_1'; +const O = 'mem_old'; +const N = 'mem_new'; + +describe('repointMemberRelations', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFindMemberForeignKeys.mockResolvedValue([ + { tableName: 'Task', columnName: 'assigneeId' }, + ]); + mockRepointGenericForeignKeys.mockResolvedValue([ + { tableName: 'Task', columnName: 'assigneeId' }, + ]); + mockAssertNoDanglingMemberReferences.mockResolvedValue(undefined); + }); + + it('discovers, generically repoints, and returns stats', async () => { + const stats = await repointMemberRelations(db, ORG_ID, O, N); + + expect(mockFindMemberForeignKeys).toHaveBeenCalledWith(db); + expect(mockRepointGenericForeignKeys).toHaveBeenCalledWith( + db, + [{ tableName: 'Task', columnName: 'assigneeId' }], + new Set([ + 'background_check_requests', + 'OffboardingChecklistCompletion', + 'OffboardingAccessRevocation', + 'EmployeeTrainingVideoCompletion', + ]), + O, + N, + ); + expect(stats).toEqual({ + foreignKeysDiscovered: 1, + genericRepointed: ['Task.assigneeId'], + signedByPoliciesUpdated: 0, + }); + }); + + it('runs the safety check with the full discovered FK list plus the non-FK extras', async () => { + await repointMemberRelations(db, ORG_ID, O, N); + + expect(mockAssertNoDanglingMemberReferences).toHaveBeenCalledWith( + db, + [{ tableName: 'Task', columnName: 'assigneeId' }], + O, + expect.objectContaining({ + 'IsmsObjective.ownerMemberId': expect.any(Function), + 'Policy.signedBy': expect.any(Function), + }), + ); + }); + + it('propagates the safety-check error instead of swallowing it', async () => { + mockAssertNoDanglingMemberReferences.mockRejectedValue( + new Error( + 'Merge safety check failed: member mem_old is still referenced by: Task.assigneeId', + ), + ); + + await expect( + repointMemberRelations(db as never, ORG_ID, O, N), + ).rejects.toThrow('Merge safety check failed'); + }); + + describe('BackgroundCheckRequest (unique on organizationId, memberId)', () => { + it('drops the old row when the new member already has one', async () => { + (db.backgroundCheckRequest.findUnique as jest.Mock).mockResolvedValue({ + id: 'bg_new', + }); + + await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.backgroundCheckRequest.deleteMany).toHaveBeenCalledWith({ + where: { memberId: O }, + }); + expect(db.backgroundCheckRequest.updateMany).not.toHaveBeenCalled(); + }); + + it('migrates the old row when the new member has none', async () => { + (db.backgroundCheckRequest.findUnique as jest.Mock).mockResolvedValue( + null, + ); + + await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.backgroundCheckRequest.updateMany).toHaveBeenCalledWith({ + where: { memberId: O }, + data: { memberId: N }, + }); + 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 === O + ? [ + { id: 'occ_1', templateItemId: 'tpl_1' }, + { id: 'occ_2', templateItemId: 'tpl_2' }, + ] + : [{ templateItemId: 'tpl_2' }], + ); + + await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.offboardingChecklistCompletion.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['occ_1'] } }, + data: { memberId: N }, + }); + expect(db.offboardingChecklistCompletion.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['occ_2'] } }, + }); + }); + + it('migrates non-duplicate OffboardingAccessRevocation rows and drops duplicates', async () => { + (db.offboardingAccessRevocation.findMany as jest.Mock).mockImplementation( + ({ where }: { where: { memberId: string } }) => + where.memberId === O + ? [ + { id: 'oar_1', vendorId: 'vnd_1' }, + { id: 'oar_2', vendorId: 'vnd_2' }, + ] + : [{ vendorId: 'vnd_2' }], + ); + + await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.offboardingAccessRevocation.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['oar_1'] } }, + data: { memberId: N }, + }); + expect(db.offboardingAccessRevocation.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['oar_2'] } }, + }); + }); + + 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 === O + ? [ + { id: 'evc_1', videoId: 'vid_1' }, + { id: 'evc_2', videoId: 'vid_2' }, + ] + : [{ videoId: 'vid_2' }], + ); + + await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.employeeTrainingVideoCompletion.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['evc_1'] } }, + data: { memberId: N }, + }); + // 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('replaces the old member id inside Policy.signedBy arrays and reports the count', async () => { + (db.policy.findMany as jest.Mock).mockResolvedValue([ + { id: 'pol_1', signedBy: [O, 'mem_other'] }, + ]); + + const stats = await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.policy.update).toHaveBeenCalledWith({ + where: { id: 'pol_1' }, + data: { signedBy: [N, 'mem_other'] }, + }); + expect(stats.signedByPoliciesUpdated).toBe(1); + }); + + it('re-points IsmsObjective.ownerMemberId, which has no real foreign key', async () => { + await repointMemberRelations(db, ORG_ID, O, N); + + expect(db.ismsObjective.updateMany).toHaveBeenCalledWith({ + where: { ownerMemberId: O }, + data: { ownerMemberId: N }, + }); + }); +}); diff --git a/apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.ts b/apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.ts new file mode 100644 index 0000000000..10e3ed5d6e --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-member-relations.ts @@ -0,0 +1,239 @@ +import { Prisma } from '@db'; +import { + assertNoDanglingMemberReferences, + findMemberForeignKeys, + repointGenericForeignKeys, +} from './merge-duplicate-user-fk-discovery'; + +/** + * Re-points every relation that points at Member.id when merging a + * duplicate member into a surviving one. Most relations are discovered from + * Postgres's catalog (see merge-duplicate-user-fk-discovery.ts) so newly + * added ones are picked up automatically. Two categories still need + * hand-written logic: + * + * - `UNIQUE_CONSTRAINT_EXCEPTIONS`: tables where the member-id column shares + * a unique constraint with another field. A blind `UPDATE` would violate + * that constraint if the new member already has a matching row, so these + * skip the duplicate row instead of moving it. + * - Columns with no real database-level foreign key (`Policy.signedBy`, a + * `String[]`, and `IsmsObjective.ownerMemberId`, a plain `String`). These + * can never be found via catalog introspection, so they stay explicit. + */ + +// Physical table names (post @@map, case-sensitive) whose member-id column +// carries a unique constraint alongside another field. +const UNIQUE_CONSTRAINT_EXCEPTIONS = new Set([ + 'background_check_requests', // @@unique([organizationId, memberId]) + 'OffboardingChecklistCompletion', // @@unique([memberId, templateItemId]) + 'OffboardingAccessRevocation', // @@unique([memberId, vendorId]) + 'EmployeeTrainingVideoCompletion', // @@unique([memberId, videoId]) +]); + +/** Splits rows keyed on the old member into ones to migrate vs. drop, based on whether the new member already has a row with the same dedupe key. */ +function splitDuplicates( + oldRows: T[], + newKeys: Set, + keyOf: (row: T) => string, +): { toMigrate: T[]; toDrop: T[] } { + const toMigrate: T[] = []; + const toDrop: T[] = []; + for (const row of oldRows) { + (newKeys.has(keyOf(row)) ? toDrop : toMigrate).push(row); + } + return { toMigrate, toDrop }; +} + +async function repointUniqueConstrainedExceptions( + tx: Prisma.TransactionClient, + organizationId: string, + o: string, + n: string, +): Promise { + // BackgroundCheckRequest: unique (organizationId, memberId) — a single row + // per member per org, so this is a delete-or-move, not a list-and-diff. + 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 }, + }); + } + + // OffboardingChecklistCompletion: unique (memberId, templateItemId) + const existingChecklist = await tx.offboardingChecklistCompletion.findMany({ + where: { memberId: o }, + select: { id: true, templateItemId: true }, + }); + const newChecklistKeys = new Set( + ( + await tx.offboardingChecklistCompletion.findMany({ + where: { memberId: n }, + select: { templateItemId: true }, + }) + ).map((c) => String(c.templateItemId)), + ); + const checklistSplit = splitDuplicates( + existingChecklist, + newChecklistKeys, + (c) => String(c.templateItemId), + ); + if (checklistSplit.toMigrate.length > 0) { + await tx.offboardingChecklistCompletion.updateMany({ + where: { id: { in: checklistSplit.toMigrate.map((c) => c.id) } }, + data: { memberId: n }, + }); + } + if (checklistSplit.toDrop.length > 0) { + await tx.offboardingChecklistCompletion.deleteMany({ + where: { id: { in: checklistSplit.toDrop.map((c) => c.id) } }, + }); + } + + // OffboardingAccessRevocation: unique (memberId, vendorId) + const existingRevocations = await tx.offboardingAccessRevocation.findMany({ + where: { memberId: o }, + select: { id: true, vendorId: true }, + }); + const newRevocationKeys = new Set( + ( + await tx.offboardingAccessRevocation.findMany({ + where: { memberId: n }, + select: { vendorId: true }, + }) + ).map((r) => r.vendorId), + ); + const revocationSplit = splitDuplicates( + existingRevocations, + newRevocationKeys, + (r) => r.vendorId, + ); + if (revocationSplit.toMigrate.length > 0) { + await tx.offboardingAccessRevocation.updateMany({ + where: { id: { in: revocationSplit.toMigrate.map((r) => r.id) } }, + data: { memberId: n }, + }); + } + if (revocationSplit.toDrop.length > 0) { + await tx.offboardingAccessRevocation.deleteMany({ + where: { id: { in: revocationSplit.toDrop.map((r) => r.id) } }, + }); + } + + // EmployeeTrainingVideoCompletion: unique (memberId, videoId) — no delete + // branch; a dropped duplicate is left on the old member and cleaned up by + // the member.delete cascade. + const existingCompletions = await tx.employeeTrainingVideoCompletion.findMany( + { + where: { memberId: o }, + select: { id: true, videoId: true }, + }, + ); + const newCompletionKeys = new Set( + ( + await tx.employeeTrainingVideoCompletion.findMany({ + where: { memberId: n }, + select: { videoId: true }, + }) + ).map((c) => c.videoId), + ); + const completionSplit = splitDuplicates( + existingCompletions, + newCompletionKeys, + (c) => c.videoId, + ); + if (completionSplit.toMigrate.length > 0) { + await tx.employeeTrainingVideoCompletion.updateMany({ + where: { id: { in: completionSplit.toMigrate.map((c) => c.id) } }, + data: { memberId: n }, + }); + } +} + +/** Fields that hold a member id but carry no real database-level foreign key, so catalog introspection can never find them. Returns the count of Policy rows updated, for logging. */ +async function repointNonForeignKeyExceptions( + tx: Prisma.TransactionClient, + o: string, + n: string, +): Promise { + // Policy.signedBy: String[] — replace the id inside 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 }, + }); + } + + // IsmsObjective.ownerMemberId: plain id, no FK — re-point to avoid a + // dangling reference. + await tx.ismsObjective.updateMany({ + where: { ownerMemberId: o }, + data: { ownerMemberId: n }, + }); + + return policiesWithSignature.length; +} + +export interface MemberRelationStats { + foreignKeysDiscovered: number; + genericRepointed: string[]; + signedByPoliciesUpdated: number; +} + +/** + * Re-points every relation from `oldMemberId` to `newMemberId`, then throws + * if anything is still left pointing at `oldMemberId` — see + * assertNoDanglingMemberReferences for why that check matters. + */ +export async function repointMemberRelations( + tx: Prisma.TransactionClient, + organizationId: string, + oldMemberId: string, + newMemberId: string, +): Promise { + const foreignKeys = await findMemberForeignKeys(tx); + + const genericRepointed = await repointGenericForeignKeys( + tx, + foreignKeys, + UNIQUE_CONSTRAINT_EXCEPTIONS, + oldMemberId, + newMemberId, + ); + await repointUniqueConstrainedExceptions( + tx, + organizationId, + oldMemberId, + newMemberId, + ); + const signedByPoliciesUpdated = await repointNonForeignKeyExceptions( + tx, + oldMemberId, + newMemberId, + ); + + await assertNoDanglingMemberReferences(tx, foreignKeys, oldMemberId, { + 'IsmsObjective.ownerMemberId': () => + tx.ismsObjective.count({ where: { ownerMemberId: oldMemberId } }), + 'Policy.signedBy': () => + tx.policy.count({ where: { signedBy: { has: oldMemberId } } }), + }); + + return { + foreignKeysDiscovered: foreignKeys.length, + genericRepointed: genericRepointed.map( + (fk) => `${fk.tableName}.${fk.columnName}`, + ), + signedByPoliciesUpdated, + }; +} 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 f7c820b94f..e9af64d04e 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 @@ -1,10 +1,17 @@ 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. +// Companion to merge-duplicate-user.spec.ts: field-by-field coverage of +// every USER-scoped relation re-pointed by the merge. Split out to keep each +// spec file under the project's 300-line limit. +// +// Member-scoped relations moved to their own dedicated specs when that +// side switched to catalog-driven FK discovery: +// - merge-duplicate-user-fk-discovery.spec.ts (the generic discovery/repoint/ +// dangling-check mechanism) +// - merge-duplicate-user-member-relations.spec.ts (orchestration + the +// hand-written exceptions: unique-constraint dedupe, Policy.signedBy, +// IsmsObjective.ownerMemberId) jest.mock('@trigger.dev/sdk', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, @@ -17,6 +24,15 @@ jest.mock('@db', () => { return { db: createDbProxyMock() }; }); +// See merge-duplicate-user.spec.ts for why this is mocked here too. +jest.mock('./merge-duplicate-user-member-relations', () => ({ + repointMemberRelations: jest.fn().mockResolvedValue({ + foreignKeysDiscovered: 0, + genericRepointed: [], + signedByPoliciesUpdated: 0, + }), +})); + interface MergeDuplicateUserRunnable { run: (params: { organizationId: string; @@ -40,7 +56,7 @@ 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', () => { +describe('mergeDuplicateUser user-relation re-pointing', () => { beforeEach(() => { jest.clearAllMocks(); @@ -61,163 +77,12 @@ describe('mergeDuplicateUser relation re-pointing', () => { (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 }, - }); - expect(db.ismsObjective.updateMany).toHaveBeenCalledWith({ - where: { ownerMemberId: o }, - data: { ownerMemberId: 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 and drops duplicates', 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'] } }, - }); - }); - - 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 }); + await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); const o = 'usr_old'; const n = 'usr_new'; @@ -260,7 +125,11 @@ describe('mergeDuplicateUser relation re-pointing', () => { }); it('deletes (not updates) the old McpOrgBinding, since userId is unique', async () => { - await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); expect(db.mcpOrgBinding.deleteMany).toHaveBeenCalledWith({ where: { userId: 'usr_old' }, 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 39e6a834d7..bb60ed5f04 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 @@ -21,6 +21,21 @@ jest.mock('@db', () => { return { db: createDbProxyMock() }; }); +// Member-relation re-pointing (catalog-driven FK discovery + exceptions) is +// its own concern with its own dedicated specs +// (merge-duplicate-user-fk-discovery.spec.ts, merge-duplicate-user-member-relations.spec.ts). +// Mocked here so this file only asserts the task's orchestration: that it's +// called with the right ids and that the transaction proceeds around it. +const mockRepointMemberRelations = jest.fn().mockResolvedValue({ + foreignKeysDiscovered: 0, + genericRepointed: [], + signedByPoliciesUpdated: 0, +}); +jest.mock('./merge-duplicate-user-member-relations', () => ({ + repointMemberRelations: (...args: unknown[]) => + mockRepointMemberRelations(...args), +})); + // 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. @@ -123,7 +138,11 @@ describe('mergeDuplicateUser', () => { (db.user.findUnique as jest.Mock).mockResolvedValue(null); await expect( - runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }), ).rejects.toThrow(`Old user not found: ${OLD_EMAIL}`); }); @@ -131,7 +150,11 @@ describe('mergeDuplicateUser', () => { (db.member.findFirst as jest.Mock).mockResolvedValue(null); await expect( - runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }), ).rejects.toThrow(/Old member not found/); }); @@ -142,7 +165,11 @@ describe('mergeDuplicateUser', () => { ); await expect( - runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }), ).rejects.toThrow(`New user not found: ${NEW_EMAIL}`); }); @@ -153,7 +180,11 @@ describe('mergeDuplicateUser', () => { ); await expect( - runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }), + runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }), ).rejects.toThrow(/New member not found/); }); @@ -165,11 +196,15 @@ describe('mergeDuplicateUser', () => { newEmail: NEW_EMAIL, }); - expect(db.task.updateMany).toHaveBeenCalledWith({ - where: { assigneeId: 'mem_old' }, - data: { assigneeId: 'mem_new' }, + expect(mockRepointMemberRelations).toHaveBeenCalledWith( + expect.anything(), // tx + ORG_ID, + 'mem_old', + 'mem_new', + ); + expect(db.member.delete).toHaveBeenCalledWith({ + where: { id: 'mem_old' }, }); - 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 }, @@ -198,7 +233,11 @@ describe('mergeDuplicateUser', () => { }); it('keeps the old user record instead of deleting it', async () => { - await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); expect(db.user.delete).not.toHaveBeenCalled(); }); @@ -229,7 +268,9 @@ describe('mergeDuplicateUser', () => { newEmail: NEW_EMAIL, }); - expect(db.member.delete).toHaveBeenCalledWith({ where: { id: 'mem_old' } }); + 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 }, @@ -238,7 +279,11 @@ describe('mergeDuplicateUser', () => { }); 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 }); + await runMerge({ + organizationId: ORG_ID, + oldEmail: OLD_EMAIL, + newEmail: NEW_EMAIL, + }); expect(db.fleetPolicyResult.updateMany).not.toHaveBeenCalled(); expect(db.oauthAccessToken.updateMany).not.toHaveBeenCalled(); @@ -260,11 +305,15 @@ describe('mergeDuplicateUser', () => { where: { createdByAdminId: 'usr_old' }, data: { createdByAdminId: 'usr_new' }, }); - expect(db.offboardingChecklistCompletion.updateMany).not.toHaveBeenCalledWith({ + expect( + db.offboardingChecklistCompletion.updateMany, + ).not.toHaveBeenCalledWith({ where: { completedById: 'usr_old' }, data: { completedById: 'usr_new' }, }); - expect(db.offboardingAccessRevocation.updateMany).not.toHaveBeenCalledWith({ + 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 a4f590be8c..30225eb2cd 100644 --- a/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -1,6 +1,7 @@ import { db } from '@db'; import { logger, schemaTask, tags } from '@trigger.dev/sdk'; import { z } from 'zod'; +import { repointMemberRelations } from './merge-duplicate-user-member-relations'; export const mergeDuplicateUser = schemaTask({ id: 'merge-duplicate-user', @@ -77,7 +78,10 @@ export const mergeDuplicateUser = schemaTask({ // 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 } }, + where: { + userId: oldUser.id, + organizationId: { not: organizationId }, + }, }); oldUserHasOtherOrgs = otherOrgMemberships > 0; @@ -87,249 +91,25 @@ export const mergeDuplicateUser = schemaTask({ oldUserHasOtherOrgs, }); - // 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: { authorId: o }, - data: { authorId: 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: 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({ - 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: 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), + // ── Re-point every member-scoped relation ───────────────────────────── + // Discovers foreign keys pointing at Member.id from Postgres's own + // catalog (plus a small set of hand-written exceptions for unique + // constraints and non-FK columns) instead of a hand-maintained list, + // then throws if anything is still left pointing at the old member — + // see merge-duplicate-user-member-relations.ts for why. + const memberRelationStats = await repointMemberRelations( + tx, + organizationId, + o, + n, ); - 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: 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) } }, - }); - } - - // EmployeeTrainingVideoCompletion: skip videos the old member already has - const existingCompletions = - await tx.employeeTrainingVideoCompletion.findMany({ - where: { memberId: o }, - select: { id: true, 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: 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 }, - }); - - // IsmsObjective: ownerMemberId (plain id, no FK — re-point to avoid dangling reference) - await tx.ismsObjective.updateMany({ - where: { ownerMemberId: o }, - data: { ownerMemberId: n }, - }); + logger.info('Re-pointed member relations', memberRelationStats); // ── Delete old member ──────────────────────────────────────────────── + // Safety check above runs before this delete so an unhandled relation + // fails loudly here, rather than being silently cascade-deleted or + // nulled out by this delete. await tx.member.delete({ where: { id: o } }); // ── User-level relations ──────────────────────────────────────────────