NeahStable/scripts/cleanup-duplicate-events.ts
2026-01-15 13:37:05 +01:00

236 lines
8.7 KiB
TypeScript

#!/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<string, DuplicateGroup>();
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();