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); +}); 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..9a36c4fc26 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-db-mock.util.ts @@ -0,0 +1,64 @@ +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), + ), + // 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) => { + 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-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 new file mode 100644 index 0000000000..e9af64d04e --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user-relations.spec.ts @@ -0,0 +1,139 @@ +import { db } from '@db'; +import { mergeDuplicateUser } from './merge-duplicate-user'; + +// 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() }, + schemaTask: (config: unknown) => config, + tags: { add: jest.fn() }, +})); + +jest.mock('@db', () => { + const { createDbProxyMock } = require('./merge-duplicate-user-db-mock.util'); + 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; + 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 user-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 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 0000000000..bb60ed5f04 --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.spec.ts @@ -0,0 +1,322 @@ +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() }; +}); + +// 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. +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(mockRepointMemberRelations).toHaveBeenCalledWith( + expect.anything(), // tx + ORG_ID, + 'mem_old', + '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 0000000000..30225eb2cd --- /dev/null +++ b/apps/api/src/trigger/tasks/people/merge-duplicate-user.ts @@ -0,0 +1,230 @@ +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', + 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, + }); + + // ── 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, + ); + + 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 ────────────────────────────────────────────── + // 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 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 }, + ]); + }); +}); 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..70a5ec2c17 --- /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, + }; + }, +});