diff --git a/apis/api-journeys-modern/project.json b/apis/api-journeys-modern/project.json index 4f5b6a3e8a9..92a31e60c67 100644 --- a/apis/api-journeys-modern/project.json +++ b/apis/api-journeys-modern/project.json @@ -108,6 +108,12 @@ "options": { "command": "pnpm exec tsx --tsconfig apis/api-journeys-modern/tsconfig.app.json -r tsconfig-paths/register apis/api-journeys-modern/src/workers/cli.ts e2e-cleanup" } + }, + "fix-cross-team-visitors": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec tsx --tsconfig apis/api-journeys-modern/tsconfig.app.json -r tsconfig-paths/register apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.ts" + } } } } diff --git a/apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.spec.ts b/apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.spec.ts new file mode 100644 index 00000000000..ed9a8c08036 --- /dev/null +++ b/apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.spec.ts @@ -0,0 +1,294 @@ +import { prismaMock } from '../../test/prismaMock' + +import { + findMismatchedRecords, + fixCrossTeamVisitors, + fixMismatchedRecord +} from './fix-cross-team-visitors' + +describe('fix-cross-team-visitors', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const makeMismatchedRecord = (overrides = {}) => ({ + journeyVisitorId: 'jv-wrong', + journeyId: 'journey-1', + wrongVisitorId: 'visitor-wrong-team', + wrongVisitorTeamId: 'team-wrong', + userId: 'user-1', + journeyTeamId: 'team-correct', + journeyVisitorCreatedAt: new Date('2025-09-25'), + ...overrides + }) + + describe('findMismatchedRecords', () => { + it('should execute raw query to find mismatched records', async () => { + const mockRecords = [makeMismatchedRecord()] + prismaMock.$queryRaw.mockResolvedValue(mockRecords) + + const result = await findMismatchedRecords(prismaMock) + + expect(result).toEqual(mockRecords) + expect(prismaMock.$queryRaw).toHaveBeenCalled() + }) + + it('should return empty array when no mismatches exist', async () => { + prismaMock.$queryRaw.mockResolvedValue([]) + + const result = await findMismatchedRecords(prismaMock) + + expect(result).toEqual([]) + }) + }) + + describe('fixMismatchedRecord', () => { + const correctVisitor = { + id: 'visitor-correct', + teamId: 'team-correct', + userId: 'user-1' + } + + const correctJV = { + id: 'jv-correct', + journeyId: 'journey-1', + visitorId: 'visitor-correct', + activityCount: 5, + duration: 100, + lastStepViewedAt: new Date('2025-09-20'), + lastChatStartedAt: null, + lastChatPlatform: null, + lastTextResponse: 'existing', + lastRadioQuestion: null, + lastRadioOptionSubmission: null, + lastLinkAction: null, + lastMultiselectSubmission: null + } + + const wrongJV = { + id: 'jv-wrong', + journeyId: 'journey-1', + visitorId: 'visitor-wrong-team', + activityCount: 3, + duration: 50, + lastStepViewedAt: new Date('2025-09-26'), + lastChatStartedAt: new Date('2025-09-26'), + lastChatPlatform: 'facebook', + lastTextResponse: null, + lastRadioQuestion: 'What?', + lastRadioOptionSubmission: null, + lastLinkAction: 'https://link.com', + lastMultiselectSubmission: null + } + + describe('merge (correct visitor + correct JV exist)', () => { + it('should move events, merge stats, and delete wrong JV', async () => { + const record = makeMismatchedRecord() + + prismaMock.visitor.findFirst.mockResolvedValue(correctVisitor as any) + prismaMock.journeyVisitor.findUnique + .mockResolvedValueOnce(correctJV as any) + .mockResolvedValueOnce(wrongJV as any) + prismaMock.event.count.mockResolvedValue(7) + prismaMock.$transaction.mockResolvedValue([]) + + const result = await fixMismatchedRecord(prismaMock, record, false) + + expect(result.action).toBe('merged') + expect(result.eventsUpdated).toBe(7) + expect(result.correctVisitorId).toBe('visitor-correct') + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1) + expect(prismaMock.event.updateMany).toHaveBeenCalledWith({ + where: { journeyId: 'journey-1', visitorId: 'visitor-wrong-team' }, + data: { visitorId: 'visitor-correct' } + }) + expect(prismaMock.journeyVisitor.update).toHaveBeenCalledWith({ + where: { id: 'jv-correct' }, + data: expect.objectContaining({ + activityCount: { increment: 3 }, + duration: { increment: 50 }, + lastStepViewedAt: new Date('2025-09-26'), + lastChatStartedAt: new Date('2025-09-26'), + lastChatPlatform: 'facebook', + lastRadioQuestion: 'What?', + lastLinkAction: 'https://link.com' + }) + }) + expect(prismaMock.journeyVisitor.delete).toHaveBeenCalledWith({ + where: { id: 'jv-wrong' } + }) + }) + + it('should not overwrite existing fields on correct JV', async () => { + const record = makeMismatchedRecord() + const wrongJVWithText = { + ...wrongJV, + lastTextResponse: 'from-wrong' + } + + prismaMock.visitor.findFirst.mockResolvedValue(correctVisitor as any) + prismaMock.journeyVisitor.findUnique + .mockResolvedValueOnce(correctJV as any) + .mockResolvedValueOnce(wrongJVWithText as any) + prismaMock.event.count.mockResolvedValue(0) + prismaMock.$transaction.mockResolvedValue([]) + + await fixMismatchedRecord(prismaMock, record, false) + + const updateCall = prismaMock.journeyVisitor.update.mock.calls[0][0] + expect(updateCall.data).not.toHaveProperty('lastTextResponse') + }) + + it('should report merge in dry run without modifying data', async () => { + const record = makeMismatchedRecord() + + prismaMock.visitor.findFirst.mockResolvedValue(correctVisitor as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue(correctJV as any) + prismaMock.event.count.mockResolvedValue(3) + + const result = await fixMismatchedRecord(prismaMock, record, true) + + expect(result.action).toBe('merged') + expect(result.eventsUpdated).toBe(3) + expect(prismaMock.$transaction).not.toHaveBeenCalled() + }) + }) + + describe('skip cases', () => { + it('should skip when no correct visitor exists', async () => { + const record = makeMismatchedRecord() + + prismaMock.visitor.findFirst.mockResolvedValue(null) + + const result = await fixMismatchedRecord(prismaMock, record, false) + + expect(result.action).toBe('skipped') + expect(result.skipReason).toContain('no correct visitor') + expect(prismaMock.$transaction).not.toHaveBeenCalled() + }) + + it('should skip when correct visitor exists but no correct JV', async () => { + const record = makeMismatchedRecord() + + prismaMock.visitor.findFirst.mockResolvedValue(correctVisitor as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue(null) + + const result = await fixMismatchedRecord(prismaMock, record, false) + + expect(result.action).toBe('skipped') + expect(result.skipReason).toContain('no correct JourneyVisitor') + expect(prismaMock.$transaction).not.toHaveBeenCalled() + }) + + it('should skip when wrong JV disappeared before fix', async () => { + const record = makeMismatchedRecord() + + prismaMock.visitor.findFirst.mockResolvedValue(correctVisitor as any) + prismaMock.journeyVisitor.findUnique + .mockResolvedValueOnce(correctJV as any) + .mockResolvedValueOnce(null) + prismaMock.event.count.mockResolvedValue(0) + + const result = await fixMismatchedRecord(prismaMock, record, false) + + expect(result.action).toBe('skipped') + expect(result.skipReason).toContain('disappeared') + expect(prismaMock.$transaction).not.toHaveBeenCalled() + }) + }) + }) + + describe('fixCrossTeamVisitors', () => { + it('should process all mismatched records', async () => { + const records = [ + makeMismatchedRecord({ journeyVisitorId: 'jv-1' }), + makeMismatchedRecord({ journeyVisitorId: 'jv-2' }) + ] + const correctVisitor = { id: 'visitor-correct' } + const correctJV = { + id: 'jv-correct', + activityCount: 0, + duration: 0, + lastStepViewedAt: null, + lastChatStartedAt: null, + lastChatPlatform: null, + lastTextResponse: null, + lastRadioQuestion: null, + lastRadioOptionSubmission: null, + lastLinkAction: null, + lastMultiselectSubmission: null + } + + prismaMock.$queryRaw.mockResolvedValue(records) + prismaMock.visitor.findFirst.mockResolvedValue(correctVisitor as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue(correctJV as any) + prismaMock.event.count.mockResolvedValue(0) + + const results = await fixCrossTeamVisitors(prismaMock, true) + + expect(results).toHaveLength(2) + expect(results[0].action).toBe('merged') + expect(results[1].action).toBe('merged') + }) + + it('should return empty array when no mismatches found', async () => { + prismaMock.$queryRaw.mockResolvedValue([]) + + const results = await fixCrossTeamVisitors(prismaMock, true) + + expect(results).toEqual([]) + }) + + it('should continue processing on individual record errors', async () => { + const records = [ + makeMismatchedRecord({ journeyVisitorId: 'jv-1' }), + makeMismatchedRecord({ journeyVisitorId: 'jv-2' }) + ] + const correctVisitor = { id: 'visitor-correct' } + const correctJV = { + id: 'jv-correct', + activityCount: 0, + duration: 0, + lastStepViewedAt: null, + lastChatStartedAt: null, + lastChatPlatform: null, + lastTextResponse: null, + lastRadioQuestion: null, + lastRadioOptionSubmission: null, + lastLinkAction: null, + lastMultiselectSubmission: null + } + + prismaMock.$queryRaw.mockResolvedValue(records) + prismaMock.visitor.findFirst + .mockRejectedValueOnce(new Error('DB connection lost')) + .mockResolvedValueOnce(correctVisitor as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue(correctJV as any) + prismaMock.event.count.mockResolvedValue(1) + + const results = await fixCrossTeamVisitors(prismaMock, true) + + expect(results).toHaveLength(2) + expect(results[0].action).toBe('skipped') + expect(results[1].action).toBe('merged') + }) + + it('should log skipped records with reasons in summary', async () => { + const records = [makeMismatchedRecord()] + + prismaMock.$queryRaw.mockResolvedValue(records) + prismaMock.visitor.findFirst.mockResolvedValue(null) + + const consoleSpy = jest.spyOn(console, 'log') + + const results = await fixCrossTeamVisitors(prismaMock, true) + + expect(results[0].action).toBe('skipped') + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipped Records') + ) + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.ts b/apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.ts new file mode 100644 index 00000000000..49b9847a122 --- /dev/null +++ b/apis/api-journeys-modern/src/scripts/fix-cross-team-visitors.ts @@ -0,0 +1,271 @@ +import { + JourneyVisitor, + PrismaClient, + Visitor, + prisma +} from '@core/prisma/journeys/client' + +interface MismatchedRecord { + journeyVisitorId: string + journeyId: string + wrongVisitorId: string + wrongVisitorTeamId: string + userId: string + journeyTeamId: string + journeyVisitorCreatedAt: Date +} + +interface FixResult { + journeyVisitorId: string + journeyId: string + action: 'merged' | 'skipped' + skipReason?: string + wrongVisitorId: string + correctVisitorId: string | null + eventsUpdated: number +} + +export async function findMismatchedRecords( + db: PrismaClient +): Promise { + return db.$queryRaw` + SELECT + jv.id AS "journeyVisitorId", + jv."journeyId", + jv."visitorId" AS "wrongVisitorId", + v."teamId" AS "wrongVisitorTeamId", + v."userId", + j."teamId" AS "journeyTeamId", + jv."createdAt" AS "journeyVisitorCreatedAt" + FROM "JourneyVisitor" jv + JOIN "Visitor" v ON v.id = jv."visitorId" + JOIN "Journey" j ON j.id = jv."journeyId" + WHERE v."teamId" != j."teamId" + ORDER BY jv."createdAt" ASC + ` +} + +function buildMergeData( + wrongJV: JourneyVisitor, + correctJV: JourneyVisitor +): Record { + return { + activityCount: { increment: wrongJV.activityCount }, + duration: { increment: wrongJV.duration }, + ...(wrongJV.lastStepViewedAt != null && + (correctJV.lastStepViewedAt == null || + wrongJV.lastStepViewedAt > correctJV.lastStepViewedAt) + ? { lastStepViewedAt: wrongJV.lastStepViewedAt } + : {}), + ...(wrongJV.lastChatStartedAt != null && + (correctJV.lastChatStartedAt == null || + wrongJV.lastChatStartedAt > correctJV.lastChatStartedAt) + ? { + lastChatStartedAt: wrongJV.lastChatStartedAt, + lastChatPlatform: wrongJV.lastChatPlatform + } + : {}), + ...(wrongJV.lastTextResponse != null && correctJV.lastTextResponse == null + ? { lastTextResponse: wrongJV.lastTextResponse } + : {}), + ...(wrongJV.lastRadioQuestion != null && correctJV.lastRadioQuestion == null + ? { lastRadioQuestion: wrongJV.lastRadioQuestion } + : {}), + ...(wrongJV.lastRadioOptionSubmission != null && + correctJV.lastRadioOptionSubmission == null + ? { lastRadioOptionSubmission: wrongJV.lastRadioOptionSubmission } + : {}), + ...(wrongJV.lastLinkAction != null && correctJV.lastLinkAction == null + ? { lastLinkAction: wrongJV.lastLinkAction } + : {}), + ...(wrongJV.lastMultiselectSubmission != null && + correctJV.lastMultiselectSubmission == null + ? { lastMultiselectSubmission: wrongJV.lastMultiselectSubmission } + : {}) + } +} + +export async function fixMismatchedRecord( + db: PrismaClient, + record: MismatchedRecord, + dryRun: boolean +): Promise { + const result: FixResult = { + journeyVisitorId: record.journeyVisitorId, + journeyId: record.journeyId, + wrongVisitorId: record.wrongVisitorId, + correctVisitorId: null, + action: 'skipped', + eventsUpdated: 0 + } + + const correctVisitor: Visitor | null = await db.visitor.findFirst({ + where: { + teamId: record.journeyTeamId, + userId: record.userId + } + }) + + if (correctVisitor == null) { + result.skipReason = 'no correct visitor found — requires manual review' + return result + } + + result.correctVisitorId = correctVisitor.id + + const correctJV: JourneyVisitor | null = await db.journeyVisitor.findUnique({ + where: { + journeyId_visitorId: { + journeyId: record.journeyId, + visitorId: correctVisitor.id + } + } + }) + + if (correctJV == null) { + result.skipReason = + 'correct visitor exists but no correct JourneyVisitor — requires manual review' + return result + } + + const eventCount = await db.event.count({ + where: { + journeyId: record.journeyId, + visitorId: record.wrongVisitorId + } + }) + + result.eventsUpdated = eventCount + result.action = 'merged' + + if (dryRun) return result + + const wrongJV = await db.journeyVisitor.findUnique({ + where: { id: record.journeyVisitorId } + }) + + if (wrongJV == null) { + result.action = 'skipped' + result.skipReason = 'wrong JourneyVisitor disappeared before fix' + return result + } + + await db.$transaction([ + db.event.updateMany({ + where: { + journeyId: record.journeyId, + visitorId: record.wrongVisitorId + }, + data: { + visitorId: correctVisitor.id + } + }), + db.journeyVisitor.update({ + where: { id: correctJV.id }, + data: buildMergeData(wrongJV, correctJV) + }), + db.journeyVisitor.delete({ + where: { id: record.journeyVisitorId } + }) + ]) + + return result +} + +export async function fixCrossTeamVisitors( + db: PrismaClient, + dryRun = true +): Promise { + const records = await findMismatchedRecords(db) + + console.log( + `Found ${records.length} mismatched JourneyVisitor records${dryRun ? ' (DRY RUN)' : ''}` + ) + + if (records.length === 0) return [] + + const results: FixResult[] = [] + + for (const record of records) { + console.log( + `\nProcessing JourneyVisitor ${record.journeyVisitorId}:`, + `\n Journey: ${record.journeyId} (team: ${record.journeyTeamId})`, + `\n Wrong Visitor: ${record.wrongVisitorId} (team: ${record.wrongVisitorTeamId})`, + `\n User: ${record.userId}` + ) + + try { + const fixResult = await fixMismatchedRecord(db, record, dryRun) + results.push(fixResult) + + const statusLine = + fixResult.action === 'merged' + ? ` Action: merged | Correct Visitor: ${fixResult.correctVisitorId} | Events updated: ${fixResult.eventsUpdated}` + : ` SKIPPED: ${fixResult.skipReason}` + + console.log(statusLine) + } catch (error) { + console.error( + ` ERROR processing ${record.journeyVisitorId}:`, + error instanceof Error ? error.message : error + ) + results.push({ + journeyVisitorId: record.journeyVisitorId, + journeyId: record.journeyId, + wrongVisitorId: record.wrongVisitorId, + correctVisitorId: null, + action: 'skipped', + skipReason: error instanceof Error ? error.message : 'unknown error', + eventsUpdated: 0 + }) + } + } + + const merged = results.filter((r) => r.action === 'merged') + const skipped = results.filter((r) => r.action === 'skipped') + + console.log('\n--- Summary ---') + console.log(`Total mismatched records: ${results.length}`) + console.log(`Merged: ${merged.length}`) + console.log(`Skipped: ${skipped.length}`) + console.log( + `Total events updated: ${results.reduce((sum, r) => sum + r.eventsUpdated, 0)}` + ) + + if (skipped.length > 0) { + console.log('\n--- Skipped Records (require manual review) ---') + for (const s of skipped) { + console.log( + ` JV: ${s.journeyVisitorId} | Journey: ${s.journeyId} | Reason: ${s.skipReason}` + ) + } + } + + return results +} + +async function main(): Promise { + const dryRun = !process.argv.includes('--apply') + + if (dryRun) { + console.log('=== DRY RUN MODE ===') + console.log('Pass --apply to execute changes\n') + } else { + console.log('=== APPLYING FIXES ===\n') + } + + try { + await fixCrossTeamVisitors(prisma, dryRun) + } finally { + await prisma.$disconnect() + } +} + +if (require.main === module) { + main().catch((error) => { + console.error('Unhandled error:', error) + process.exit(1) + }) +} + +export default main