diff --git a/lib/services/microsoft-calendar-sync.ts b/lib/services/microsoft-calendar-sync.ts index d8ca774..7f4c832 100644 --- a/lib/services/microsoft-calendar-sync.ts +++ b/lib/services/microsoft-calendar-sync.ts @@ -507,7 +507,8 @@ export async function syncMicrosoftCalendar( // Priority 3: Fallback to title + date matching for events without externalEventId // IMPORTANT: Only match if the event doesn't have an externalEventId (to avoid false matches) - if (!existingEvent) { + // This helps migrate old events that were created before externalEventId was added + if (!existingEvent && microsoftId) { existingEvent = existingEvents.find( (e) => { // Access externalEventId safely (may not be in Prisma type if client not regenerated) @@ -521,10 +522,11 @@ export async function syncMicrosoftCalendar( if (e.title === caldavEvent.summary) { const timeDiff = Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()); if (timeDiff < 60000) { // Within 1 minute - logger.debug('Matched event by title + date (no externalEventId)', { + logger.debug('Matched event by title + date (no externalEventId) - will update with externalEventId', { eventId: e.id, title: caldavEvent.summary, timeDiff, + microsoftId, }); return true; } diff --git a/scripts/cleanup-duplicate-events.ts b/scripts/cleanup-duplicate-events.ts new file mode 100644 index 0000000..4474760 --- /dev/null +++ b/scripts/cleanup-duplicate-events.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env ts-node + +/** + * Script to clean up duplicate events in the database + * Keeps events with externalEventId, removes duplicates without externalEventId + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface DuplicateGroup { + title: string; + start: Date; + end: Date; + calendarId: string; + events: Array<{ + id: string; + externalEventId: string | null; + }>; +} + +async function cleanupDuplicateEvents() { + try { + console.log('šŸ” Searching for duplicate events...\n'); + + // Find all events grouped by title, start, end, and calendarId + const allEvents = await prisma.event.findMany({ + select: { + id: true, + title: true, + start: true, + end: true, + calendarId: true, + externalEventId: true, + }, + orderBy: [ + { calendarId: 'asc' }, + { title: 'asc' }, + { start: 'asc' }, + ], + }); + + // Group events by title + start + end + calendarId + const eventGroups = new Map(); + + for (const event of allEvents) { + const key = `${event.calendarId}|${event.title}|${event.start.toISOString()}|${event.end.toISOString()}`; + + if (!eventGroups.has(key)) { + eventGroups.set(key, { + title: event.title, + start: event.start, + end: event.end, + calendarId: event.calendarId, + events: [], + }); + } + + eventGroups.get(key)!.events.push({ + id: event.id, + externalEventId: event.externalEventId, + }); + } + + // Find duplicates (groups with more than 1 event) + const duplicates: DuplicateGroup[] = []; + for (const group of eventGroups.values()) { + if (group.events.length > 1) { + duplicates.push(group); + } + } + + console.log(`Found ${duplicates.length} groups of duplicate events\n`); + + if (duplicates.length === 0) { + console.log('āœ… No duplicates found!'); + return; + } + + let totalDeleted = 0; + let totalKept = 0; + + for (const group of duplicates) { + console.log(`\nšŸ“‹ Processing: "${group.title}" (${group.events.length} duplicates)`); + console.log(` Calendar: ${group.calendarId}`); + console.log(` Date: ${group.start.toISOString()}`); + + // Separate events with and without externalEventId + const withExternalId = group.events.filter(e => e.externalEventId); + const withoutExternalId = group.events.filter(e => !e.externalEventId); + + if (withExternalId.length > 1) { + // Multiple events with externalEventId - keep the first one, delete others + console.log(` āš ļø Warning: Multiple events with externalEventId found!`); + const toKeep = withExternalId[0]; + const toDelete = [...withExternalId.slice(1), ...withoutExternalId]; + + console.log(` āœ… Keeping: ${toKeep.id} (has externalEventId)`); + for (const event of toDelete) { + console.log(` šŸ—‘ļø Deleting: ${event.id}`); + await prisma.event.delete({ where: { id: event.id } }); + totalDeleted++; + } + totalKept++; + } else if (withExternalId.length === 1) { + // One event with externalEventId - keep it, delete others + const toKeep = withExternalId[0]; + const toDelete = withoutExternalId; + + console.log(` āœ… Keeping: ${toKeep.id} (has externalEventId)`); + for (const event of toDelete) { + console.log(` šŸ—‘ļø Deleting: ${event.id}`); + await prisma.event.delete({ where: { id: event.id } }); + totalDeleted++; + } + totalKept++; + } else { + // No events with externalEventId - keep the first one, delete others + const toKeep = group.events[0]; + const toDelete = group.events.slice(1); + + console.log(` āœ… Keeping: ${toKeep.id} (no externalEventId, keeping first)`); + for (const event of toDelete) { + console.log(` šŸ—‘ļø Deleting: ${event.id}`); + await prisma.event.delete({ where: { id: event.id } }); + totalDeleted++; + } + totalKept++; + } + } + + console.log(`\nāœ… Cleanup completed!`); + console.log(` Kept: ${totalKept} events`); + console.log(` Deleted: ${totalDeleted} duplicate events`); + + } catch (error) { + console.error('āŒ Error cleaning up duplicates:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +cleanupDuplicateEvents();