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 000000000..db1653897 --- /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); +}); 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 000000000..79bca6727 --- /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 000000000..f7c820b94 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts @@ -0,0 +1,270 @@ +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 }, + }); + 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 }); + + 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 }, + }); + expect(db.offboardingAccessRevocation.updateMany).toHaveBeenCalledWith({ + where: { revokedById: o }, + data: { revokedById: 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 000000000..39e6a834d --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts @@ -0,0 +1,273 @@ +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; + userLevelRelationsMerged: boolean; + mergedUserId: string; + mergedMemberId: string; + }>; +} + +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'; + +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); + }); + + 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); + + 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 clears the old user sessions', 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(result.userLevelRelationsMerged).toBe(true); + expect(result.mergedUserId).toBe('usr_old'); + expect(result.survivingUserId).toBe('usr_new'); + }); + + it('keeps the old user record instead of deleting it', async () => { + await runMerge({ organizationId: ORG_ID, oldEmail: OLD_EMAIL, newEmail: NEW_EMAIL }); + + expect(db.user.delete).not.toHaveBeenCalled(); + }); + }); + + 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 and session 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.userLevelRelationsMerged).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' }, + }); + 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 new file mode 100644 index 000000000..a4f590be8 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -0,0 +1,450 @@ +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(), + }) + .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}`]); + + // ── 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 ──────────────────────────────────────── + + 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 }, + 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), + ); + 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 }, + }); + + // ── Delete old member ──────────────────────────────────────────────── + await tx.member.delete({ where: { id: o } }); + + // ── User-level relations ────────────────────────────────────────────── + // Only safe when the old user has no membership in any other org — + // 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({ + 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 }, + }); + + // 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 }, + }); + + // 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 }, + }); + + // 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 } }); + } + + // ── Update pending invitations ─────────────────────────────────────── + await tx.invitation.updateMany({ + where: { email: oldEmail, organizationId }, + data: { email: newEmail }, + }); + }, + { timeout: 30000 }, + ); + + logger.info('Merge complete', { + organizationId, + oldEmail, + newEmail, + survivingUserId: newUser.id, + survivingMemberId: newMember.id, + userLevelRelationsMerged: !oldUserHasOtherOrgs, + }); + + return { + success: true, + survivingUserId: newUser.id, + survivingMemberId: newMember.id, + userLevelRelationsMerged: !oldUserHasOtherOrgs, + mergedUserId: oldUser.id, + mergedMemberId: oldMember.id, + }; + }, +}); 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 000000000..0c79aa3a8 --- /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 }, + ]); + }); +}); 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 000000000..70a5ec2c1 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/migrate-org-email-domain.ts @@ -0,0 +1,96 @@ +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}`]); + + 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({ + where: { organizationId, isActive: true, deactivated: false }, + select: { + user: { select: { email: true } }, + }, + }); + + 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 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 = 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 }); + 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, + }; + }, +});