#!/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, isAllDay: true, }, orderBy: [ { calendarId: 'asc' }, { title: 'asc' }, { start: 'asc' }, ], }); // Group events by title + start + end + calendarId // For all-day events, compare by date only (YYYY-MM-DD), not exact time const eventGroups = new Map(); for (const event of allEvents) { // Normalize dates to compare: for all-day events, use date only // For timed events, round to nearest minute to handle small time differences const startDate = new Date(event.start); const endDate = new Date(event.end); // Check if it's an all-day event (use isAllDay field or detect by duration) const duration = endDate.getTime() - startDate.getTime(); const isAllDay = event.isAllDay || (duration >= 23 * 60 * 60 * 1000 && duration <= 25 * 60 * 60 * 1000); // 23-25 hours let startKey: string; let endKey: string; if (isAllDay) { // For all-day events, compare by date only (YYYY-MM-DD) // Use UTC date to avoid timezone issues const startUTC = new Date(Date.UTC( startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() )); const endUTC = new Date(Date.UTC( endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate() )); startKey = `${startUTC.getUTCFullYear()}-${String(startUTC.getUTCMonth() + 1).padStart(2, '0')}-${String(startUTC.getUTCDate()).padStart(2, '0')}`; endKey = `${endUTC.getUTCFullYear()}-${String(endUTC.getUTCMonth() + 1).padStart(2, '0')}-${String(endUTC.getUTCDate()).padStart(2, '0')}`; } else { // For timed events, round to nearest minute to handle small differences const startRounded = new Date(startDate); startRounded.setSeconds(0, 0); const endRounded = new Date(endDate); endRounded.setSeconds(0, 0); startKey = startRounded.toISOString(); endKey = endRounded.toISOString(); } const key = `${event.calendarId}|${event.title}|${startKey}|${endKey}`; 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`); // Debug: Show all events for specific titles to understand grouping const debugTitles = ['Vendredi saint', 'Dimanche de PΓ’ques']; for (const title of debugTitles) { const titleEvents = allEvents.filter(e => e.title === title); if (titleEvents.length > 0) { console.log(`\nπŸ” Debug: Events for "${title}":`); for (const evt of titleEvents) { const startDate = new Date(evt.start); const endDate = new Date(evt.end); const duration = endDate.getTime() - startDate.getTime(); const isAllDay = evt.isAllDay || (duration >= 23 * 60 * 60 * 1000 && duration <= 25 * 60 * 60 * 1000); console.log(` - ID: ${evt.id}`); console.log(` Calendar: ${evt.calendarId}`); console.log(` Start: ${evt.start.toISOString()} (${startDate.toLocaleDateString()})`); console.log(` End: ${evt.end.toISOString()} (${endDate.toLocaleDateString()})`); console.log(` Duration: ${duration / (60 * 60 * 1000)} hours`); console.log(` isAllDay: ${evt.isAllDay}, detected: ${isAllDay}`); console.log(` externalEventId: ${evt.externalEventId ? 'YES' : 'NO'}`); // Show what key would be generated let startKey: string; let endKey: string; if (isAllDay) { const startUTC = new Date(Date.UTC( startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() )); const endUTC = new Date(Date.UTC( endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate() )); startKey = `${startUTC.getUTCFullYear()}-${String(startUTC.getUTCMonth() + 1).padStart(2, '0')}-${String(startUTC.getUTCDate()).padStart(2, '0')}`; endKey = `${endUTC.getUTCFullYear()}-${String(endUTC.getUTCMonth() + 1).padStart(2, '0')}-${String(endUTC.getUTCDate()).padStart(2, '0')}`; } else { const startRounded = new Date(startDate); startRounded.setSeconds(0, 0); const endRounded = new Date(endDate); endRounded.setSeconds(0, 0); startKey = startRounded.toISOString(); endKey = endRounded.toISOString(); } const key = `${evt.calendarId}|${evt.title}|${startKey}|${endKey}`; console.log(` Group key: ${key}`); } } } if (duplicates.length === 0) { console.log('\nβœ… No duplicates found!'); console.log(' (Check debug output above to see why events are not grouped as duplicates)'); 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();