diff --git a/scripts/consolidate-private-calendars.ts b/scripts/consolidate-private-calendars.ts new file mode 100644 index 0000000..3ca55dc --- /dev/null +++ b/scripts/consolidate-private-calendars.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env ts-node + +/** + * Script to consolidate duplicate "Privée" calendars + * - Identifies the calendar to keep (with active sync and most events) + * - Migrates events from duplicate calendars to the one to keep + * - Deletes duplicate calendars and their sync configs + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function consolidatePrivateCalendars() { + try { + console.log('🔍 Searching for duplicate "Privée" calendars...\n'); + + // Find all calendars named "Privée" + const privateCalendars = await prisma.calendar.findMany({ + where: { + name: 'Privée', + }, + include: { + syncConfig: { + include: { + mailCredential: true, + }, + }, + _count: { + select: { + events: true, + }, + }, + }, + }); + + console.log(`Found ${privateCalendars.length} calendars named "Privée"\n`); + + if (privateCalendars.length <= 1) { + console.log('✅ No duplicates found. Nothing to consolidate.\n'); + return; + } + + // Find the calendar to keep: + // 1. Has active sync (syncEnabled = true) + // 2. Has the most events + // 3. Most recent + const calendarsToSort = [...privateCalendars]; + calendarsToSort.sort((a, b) => { + // Priority 1: Active sync + const aHasActiveSync = a.syncConfig?.syncEnabled === true; + const bHasActiveSync = b.syncConfig?.syncEnabled === true; + if (aHasActiveSync && !bHasActiveSync) return -1; + if (!aHasActiveSync && bHasActiveSync) return 1; + + // Priority 2: Most events + const aEventCount = a._count.events; + const bEventCount = b._count.events; + if (aEventCount !== bEventCount) { + return bEventCount - aEventCount; // Descending + } + + // Priority 3: Most recent + return b.createdAt.getTime() - a.createdAt.getTime(); // Descending + }); + + const calendarToKeep = calendarsToSort[0]; + const calendarsToDelete = calendarsToSort.slice(1); + + console.log('📋 Calendar to KEEP:'); + console.log(` ID: ${calendarToKeep.id}`); + console.log(` Events: ${calendarToKeep._count.events}`); + console.log(` Sync enabled: ${calendarToKeep.syncConfig?.syncEnabled || false}`); + console.log(` Provider: ${calendarToKeep.syncConfig?.provider || 'none'}`); + console.log(` Created: ${calendarToKeep.createdAt}\n`); + + console.log(`🗑️ Calendars to DELETE (${calendarsToDelete.length}):`); + for (const cal of calendarsToDelete) { + console.log(` ID: ${cal.id}`); + console.log(` Events: ${cal._count.events}`); + console.log(` Sync enabled: ${cal.syncConfig?.syncEnabled || false}`); + console.log(` Created: ${cal.createdAt}\n`); + } + + // Migrate events from calendars to delete to the calendar to keep + let totalMigrated = 0; + for (const calToDelete of calendarsToDelete) { + const eventsToMigrate = await prisma.event.findMany({ + where: { + calendarId: calToDelete.id, + }, + }); + + console.log(`📦 Migrating ${eventsToMigrate.length} events from calendar ${calToDelete.id}...`); + + for (const event of eventsToMigrate) { + // Check if event with same externalEventId already exists in target calendar + const externalId = (event as any).externalEventId; + + if (externalId) { + // Check if event with this externalEventId already exists in target calendar + const existingEvent = await prisma.event.findFirst({ + where: { + calendarId: calendarToKeep.id, + externalEventId: externalId, + }, + }); + + if (existingEvent) { + console.log(` ⚠️ Skipping event "${event.title}" - already exists in target calendar (externalEventId: ${externalId.substring(0, 50)}...)`); + // Delete the duplicate event + await prisma.event.delete({ + where: { id: event.id }, + }); + continue; + } + } else { + // For events without externalEventId, check by title + date + const existingEvent = await prisma.event.findFirst({ + where: { + calendarId: calendarToKeep.id, + title: event.title, + start: event.start, + }, + }); + + if (existingEvent) { + console.log(` ⚠️ Skipping event "${event.title}" - already exists in target calendar (title + date match)`); + // Delete the duplicate event + await prisma.event.delete({ + where: { id: event.id }, + }); + continue; + } + } + + // Migrate event to target calendar + try { + await prisma.event.update({ + where: { id: event.id }, + data: { + calendarId: calendarToKeep.id, + }, + }); + totalMigrated++; + console.log(` ✅ Migrated: "${event.title}"`); + } catch (error) { + console.error(` ❌ Error migrating event "${event.title}":`, error); + } + } + } + + console.log(`\n✅ Migrated ${totalMigrated} events to calendar ${calendarToKeep.id}\n`); + + // Delete duplicate calendars and their sync configs + for (const calToDelete of calendarsToDelete) { + console.log(`🗑️ Deleting calendar ${calToDelete.id}...`); + + // Delete sync config if exists + if (calToDelete.syncConfig) { + await prisma.calendarSync.delete({ + where: { id: calToDelete.syncConfig.id }, + }); + console.log(` ✅ Deleted sync config ${calToDelete.syncConfig.id}`); + } + + // Delete calendar (events are already migrated or deleted) + await prisma.calendar.delete({ + where: { id: calToDelete.id }, + }); + console.log(` ✅ Deleted calendar ${calToDelete.id}`); + } + + console.log(`\n✅ Consolidation completed!`); + console.log(` Kept: 1 calendar (${calendarToKeep.id})`); + console.log(` Deleted: ${calendarsToDelete.length} duplicate calendars`); + console.log(` Migrated: ${totalMigrated} events\n`); + + } catch (error) { + console.error('❌ Error consolidating calendars:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// Run the script +consolidatePrivateCalendars() + .then(() => { + console.log('✅ Script completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ Script failed:', error); + process.exit(1); + }); diff --git a/scripts/explore-calendar-duplicates.sql b/scripts/explore-calendar-duplicates.sql new file mode 100644 index 0000000..f1e4f26 --- /dev/null +++ b/scripts/explore-calendar-duplicates.sql @@ -0,0 +1,150 @@ +-- ============================================ +-- Script d'exploration des duplications de calendriers et événements +-- ============================================ + +-- 1. Lister tous les calendriers "Privée" avec leurs détails +SELECT + c.id, + c.name, + c.color, + c.description, + c."userId", + c."missionId", + c."createdAt", + c."updatedAt", + cs.id as "syncConfigId", + cs.provider, + cs."externalCalendarId", + cs."syncEnabled", + cs."lastSyncAt", + mc.email as "mailCredentialEmail", + (SELECT COUNT(*) FROM "Event" e WHERE e."calendarId" = c.id) as "eventCount" +FROM "Calendar" c +LEFT JOIN "CalendarSync" cs ON cs."calendarId" = c.id +LEFT JOIN "MailCredentials" mc ON mc.id = cs."mailCredentialId" +WHERE c.name = 'Privée' +ORDER BY c."createdAt" DESC; + +-- 2. Voir tous les événements "Vendredi saint" avec leurs calendriers +SELECT + e.id, + e.title, + e."calendarId", + c.name as "calendarName", + e."externalEventId", + e.start, + e.end, + e."isAllDay", + e."createdAt", + e."updatedAt" +FROM "Event" e +JOIN "Calendar" c ON c.id = e."calendarId" +WHERE e.title = 'Vendredi saint' +ORDER BY e.start, c.name; + +-- 3. Identifier les événements en double par externalEventId (même événement dans plusieurs calendriers) +SELECT + e."externalEventId", + e.title, + COUNT(*) as "occurrenceCount", + STRING_AGG(DISTINCT c.id::text, ', ') as "calendarIds", + STRING_AGG(DISTINCT c.name, ', ') as "calendarNames", + MIN(e.start) as "earliestStart", + MAX(e.start) as "latestStart" +FROM "Event" e +JOIN "Calendar" c ON c.id = e."calendarId" +WHERE e."externalEventId" IS NOT NULL + AND e."externalEventId" != '' +GROUP BY e."externalEventId", e.title +HAVING COUNT(*) > 1 +ORDER BY "occurrenceCount" DESC, e.title; + +-- 4. Voir les détails complets des événements en double +SELECT + e.id, + e.title, + e."externalEventId", + e."calendarId", + c.name as "calendarName", + c."userId", + e.start, + e.end, + e."isAllDay", + e."createdAt", + e."updatedAt" +FROM "Event" e +JOIN "Calendar" c ON c.id = e."calendarId" +WHERE e."externalEventId" IN ( + SELECT "externalEventId" + FROM "Event" + WHERE "externalEventId" IS NOT NULL + AND "externalEventId" != '' + GROUP BY "externalEventId" + HAVING COUNT(*) > 1 +) +ORDER BY e."externalEventId", e."calendarId", e.start; + +-- 5. Compter les événements par calendrier "Privée" +SELECT + c.id as "calendarId", + c.name, + COUNT(e.id) as "totalEvents", + COUNT(CASE WHEN e."externalEventId" IS NOT NULL THEN 1 END) as "eventsWithExternalId", + COUNT(CASE WHEN e."externalEventId" IS NULL THEN 1 END) as "eventsWithoutExternalId" +FROM "Calendar" c +LEFT JOIN "Event" e ON e."calendarId" = c.id +WHERE c.name = 'Privée' +GROUP BY c.id, c.name +ORDER BY "totalEvents" DESC; + +-- 6. Voir les événements "TEST de connaissances" (celui qui apparaît en double dans les logs) +SELECT + e.id, + e.title, + e."externalEventId", + e."calendarId", + c.name as "calendarName", + e.start, + e.end, + e."createdAt" +FROM "Event" e +JOIN "Calendar" c ON c.id = e."calendarId" +WHERE e.title LIKE '%TEST de connaissances%' + OR e."externalEventId" = 'AAMkADg2NjBjMDUwLTJlMzYtNDBlZi1iNzE4LTA5YWFiYTdhMWJmYwBGAAAAAABiHJmWOydNSY0OtAePspbBBwBWDBinTymARZbnqCRYQq7IAAAAAAENAABWDBinTymARZbnqCRYQq7IAAMGtqjeAAA=' +ORDER BY e."calendarId", e.start; + +-- 7. Lister tous les CalendarSync pour les calendriers "Privée" +SELECT + cs.id, + cs."calendarId", + c.name as "calendarName", + cs.provider, + cs."externalCalendarId", + cs."syncEnabled", + cs."lastSyncAt", + cs."syncFrequency", + cs."lastSyncError", + mc.email as "mailCredentialEmail" +FROM "CalendarSync" cs +JOIN "Calendar" c ON c.id = cs."calendarId" +LEFT JOIN "MailCredentials" mc ON mc.id = cs."mailCredentialId" +WHERE c.name = 'Privée' +ORDER BY cs."createdAt" DESC; + +-- 8. Trouver les événements sans externalEventId dans les calendriers Microsoft +SELECT + e.id, + e.title, + e."calendarId", + c.name as "calendarName", + e.start, + e.end, + e."createdAt", + cs.provider +FROM "Event" e +JOIN "Calendar" c ON c.id = e."calendarId" +LEFT JOIN "CalendarSync" cs ON cs."calendarId" = c.id +WHERE c.name = 'Privée' + AND (e."externalEventId" IS NULL OR e."externalEventId" = '') + AND cs.provider = 'microsoft' +ORDER BY e.start DESC;