import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/options"; import { redirect } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { CalendarClient } from "@/components/calendar/calendar-client"; import { Metadata } from "next"; import { CalendarDays, Users, Bookmark, Clock } from "lucide-react"; import Image from "next/image"; import { Button } from "@/components/ui/button"; import { add } from 'date-fns'; export const metadata: Metadata = { title: "Enkun - Calendrier | Gestion d'événements professionnelle", description: "Plateforme avancée pour la gestion de vos rendez-vous, réunions et événements professionnels", keywords: "calendrier, rendez-vous, événements, gestion du temps, enkun", }; interface Event { id: string; title: string; description?: string | null; start: Date; end: Date; location?: string | null; isAllDay: boolean; type?: string; attendees?: { id: string; name: string }[]; } interface Calendar { id: string; name: string; color: string; description?: string | null; events: Event[]; } export default async function CalendarPage() { const session = await getServerSession(authOptions); if (!session?.user) { redirect("/api/auth/signin"); } const userId = session.user.username || session.user.email || ''; // Ensure user has a default private calendar (created automatically if missing) // This is the user's personal calendar, distinct from Microsoft synced calendars const defaultPrivateCalendarName = "Mon Calendrier"; const existingDefaultCalendar = await prisma.calendar.findFirst({ where: { userId: session?.user?.id || '', name: defaultPrivateCalendarName, missionId: null, // Not a mission calendar syncConfig: null, // Not a synced calendar } }); if (!existingDefaultCalendar) { // Create default private calendar for the user await prisma.calendar.create({ data: { name: defaultPrivateCalendarName, color: "#4f46e5", // Indigo color description: "Votre calendrier personnel", userId: session?.user?.id || '', missionId: null, } }); console.log(`[AGENDA] Created default private calendar for user ${session?.user?.id}`); } // Get all calendars for the user with mission relation and sync configuration // This includes: // 1. Personal calendars (userId = session.user.id) - including the default private calendar // 2. Microsoft synced calendars (named "Privée" with syncConfig) // 3. Mission calendars where user is associated via MissionUser // 4. Group calendars where user is a member // Exclude "Privée" and "Default" calendars that are not synced (they should only exist if synced from courrier) // Get personal calendars // First, get all calendars, then filter in code for "Privée"/"Default" with sync let personalCalendars = await prisma.calendar.findMany({ where: { userId: session?.user?.id || '', OR: [ // Keep calendars that are not "Privée" or "Default" { name: { notIn: ["Privée", "Default"] } }, // Or keep "Privée"/"Default" calendars that have sync config (we'll filter syncEnabled in code) { AND: [ { name: { in: ["Privée", "Default"] } }, { syncConfig: { isNot: null } } ] } ] }, include: { events: { orderBy: { start: 'asc' } }, mission: { include: { missionUsers: true } }, syncConfig: { include: { mailCredential: { select: { id: true, email: true, display_name: true, } } } } } }); // Filter out "Privée"/"Default" calendars that don't have syncEnabled=true personalCalendars = personalCalendars.filter(cal => { // Keep all non-"Privée"/"Default" calendars if (cal.name !== "Privée" && cal.name !== "Default") { return true; } // For "Privée"/"Default", only keep if syncConfig exists and is enabled return cal.syncConfig && cal.syncConfig.syncEnabled === true; }); // Get mission calendars where user is associated via MissionUser const missionUserRelations = await prisma.missionUser.findMany({ where: { userId: session?.user?.id || '', }, include: { mission: { include: { calendars: { where: { // Exclude calendars already owned by user (already in personalCalendars) userId: { not: session?.user?.id || '' } }, include: { events: { orderBy: { start: 'asc' } }, mission: { include: { missionUsers: true } }, syncConfig: { include: { mailCredential: { select: { id: true, email: true, display_name: true, } } } } } } } } } }); // Extract mission calendars const missionCalendars = missionUserRelations.flatMap(mu => mu.mission.calendars); // Debug: Log calendar filtering console.log('[AGENDA] Calendar filtering:', { personalCalendarsCount: personalCalendars.length, personalCalendars: personalCalendars.map(cal => ({ id: cal.id, name: cal.name, hasSyncConfig: !!cal.syncConfig, syncEnabled: cal.syncConfig?.syncEnabled, provider: cal.syncConfig?.provider, })), missionCalendarsCount: missionCalendars.length, }); // Combine personal and mission calendars let calendars = [...personalCalendars, ...missionCalendars]; // Auto-setup sync for email accounts from courrier (Microsoft only) // Get all Microsoft email accounts (OAuth) const microsoftAccounts = await prisma.mailCredentials.findMany({ where: { userId: session?.user?.id || '', host: { contains: 'outlook.office365.com' }, use_oauth: true, refresh_token: { not: null } }, select: { id: true, email: true, display_name: true, refresh_token: true, use_oauth: true } }); // Clean up orphaned syncs FIRST (before creating new ones) // This handles the case where a user deleted and re-added an email account const allMailCredentialIds = new Set([ ...microsoftAccounts.map(acc => acc.id) ]); const orphanedSyncs = await prisma.calendarSync.findMany({ where: { calendar: { userId: session?.user?.id || '' }, mailCredentialId: { not: null } }, include: { calendar: true, mailCredential: true } }); // Delete syncs where mailCredential no longer exists for (const sync of orphanedSyncs) { if (sync.mailCredentialId && !allMailCredentialIds.has(sync.mailCredentialId)) { console.log(`[AGENDA] Deleting orphaned sync for non-existent mailCredentialId: ${sync.mailCredentialId}`); // Delete the calendar if it has no events const eventCount = await prisma.event.count({ where: { calendarId: sync.calendarId } }); if (eventCount === 0) { await prisma.calendar.delete({ where: { id: sync.calendarId } }); } else { // Just disable the sync, keep the calendar await prisma.calendarSync.update({ where: { id: sync.id }, data: { syncEnabled: false } }); } } } // For each Microsoft account, ensure there's a synced calendar for (const account of microsoftAccounts) { // Check if a calendar sync already exists for this account (enabled or disabled) // This prevents creating duplicate calendars for the same account const existingSync = await prisma.calendarSync.findFirst({ where: { mailCredentialId: account.id }, include: { calendar: true } }); // If sync exists but is disabled, re-enable it instead of creating a new calendar if (existingSync && !existingSync.syncEnabled) { await prisma.calendarSync.update({ where: { id: existingSync.id }, data: { syncEnabled: true } }); continue; // Skip to next account } if (!existingSync) { // Try to discover calendars for this account try { const { discoverMicrosoftCalendars } = await import('@/lib/services/microsoft-calendar-sync'); const externalCalendars = await discoverMicrosoftCalendars( session?.user?.id || '', account.email ); if (externalCalendars.length > 0) { // Use the first calendar (usually the main calendar) const mainCalendar = externalCalendars[0]; // Create a private calendar for this account const calendar = await prisma.calendar.create({ data: { name: "Privée", color: "#0078D4", // Microsoft blue description: `Calendrier synchronisé avec ${account.display_name || account.email}`, userId: session?.user?.id || '', } }); // Create sync configuration // Use 5 minutes for Microsoft (more reactive than 15 minutes) await prisma.calendarSync.create({ data: { calendarId: calendar.id, mailCredentialId: account.id, provider: 'microsoft', externalCalendarId: mainCalendar.id, externalCalendarUrl: mainCalendar.webLink || mainCalendar.id, syncEnabled: true, syncFrequency: 5 } }); // Trigger initial sync try { const { syncMicrosoftCalendar } = await import('@/lib/services/microsoft-calendar-sync'); const syncConfig = await prisma.calendarSync.findUnique({ where: { calendarId: calendar.id }, include: { calendar: true, mailCredential: true } }); if (syncConfig) { await syncMicrosoftCalendar(syncConfig.id, true); } } catch (syncError) { console.error('Error during initial Microsoft sync:', syncError); // Don't fail if sync fails, calendar is still created } } } catch (error) { // Microsoft sync setup failed - likely because account doesn't have calendar scope yet // This is expected for accounts authenticated before calendar scope was added // User will need to re-authenticate their Microsoft account to get calendar access console.log(`Microsoft calendar sync not available for ${account.email} - account may need re-authentication with calendar permissions`); // Don't fail the page - continue with other accounts } } } // Clean up duplicate calendars for the same mailCredentialId // NOTE: This is mainly for cleaning up OLD duplicates created before we fixed the cascade deletion // New duplicates should not occur since we now delete calendars when mail accounts are deleted // This cleanup is conservative: only processes if there are multiple ENABLED syncs for the same account // Only run cleanup if there are actually multiple syncs to check (performance optimization) const syncCountCheck = await prisma.calendarSync.count({ where: { calendar: { userId: session?.user?.id || '' }, mailCredentialId: { in: Array.from(allMailCredentialIds) } } }); console.log(`[AGENDA] Cleanup check: ${syncCountCheck} syncs found for ${allMailCredentialIds.size} mail accounts`); // Only run full cleanup if there are multiple syncs (likely duplicates) if (syncCountCheck > allMailCredentialIds.size) { console.log(`[AGENDA] Running cleanup: ${syncCountCheck} syncs > ${allMailCredentialIds.size} accounts (potential duplicates)`); const allSyncs = await prisma.calendarSync.findMany({ where: { calendar: { userId: session?.user?.id || '' }, mailCredentialId: { in: Array.from(allMailCredentialIds) } }, include: { calendar: true, mailCredential: true }, orderBy: { createdAt: 'desc' } }); // Group by mailCredentialId and provider const syncsByAccount = new Map(); for (const sync of allSyncs) { if (sync.mailCredentialId) { const key = `${sync.mailCredentialId}-${sync.provider}`; if (!syncsByAccount.has(key)) { syncsByAccount.set(key, []); } syncsByAccount.get(key)!.push(sync); } } // For each account, keep only the most recent enabled sync, disable or delete others // IMPORTANT: Only process if there are actually multiple ENABLED syncs to avoid disabling active syncs for (const [key, syncs] of syncsByAccount.entries()) { if (syncs.length > 1) { // Count how many are actually enabled const enabledSyncs = syncs.filter(s => s.syncEnabled === true); // Only clean up if there are multiple ENABLED syncs // If there's only one enabled sync, don't touch it even if there are disabled duplicates if (enabledSyncs.length > 1) { console.log(`[AGENDA] Found ${enabledSyncs.length} enabled syncs for ${key}, cleaning up duplicates`); // Sort by syncEnabled first (enabled first), then by lastSyncAt (most recently synced first), then by createdAt (newest first) syncs.sort((a, b) => { if (a.syncEnabled !== b.syncEnabled) { return a.syncEnabled ? -1 : 1; } // Among enabled syncs, prefer the one with most recent sync if (a.syncEnabled && b.syncEnabled) { if (a.lastSyncAt && b.lastSyncAt) { return new Date(b.lastSyncAt).getTime() - new Date(a.lastSyncAt).getTime(); } if (a.lastSyncAt && !b.lastSyncAt) return -1; if (!a.lastSyncAt && b.lastSyncAt) return 1; } return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); const keepSync = syncs[0]; const duplicates = syncs.slice(1); // Additional safety check: only disable duplicates if the sync to keep is actually valid // (has events or has been synced recently) const keepSyncEventCount = await prisma.event.count({ where: { calendarId: keepSync.calendarId } }); const keepSyncIsValid = keepSyncEventCount > 0 || keepSync.lastSyncAt; if (!keepSyncIsValid) { console.log(`[AGENDA] WARNING: Sync to keep ${keepSync.id} has no events and no sync history - skipping cleanup to avoid disabling valid syncs`); continue; // Skip this group to avoid disabling a potentially valid sync } console.log(`[AGENDA] Keeping sync ${keepSync.id} (calendar: ${keepSync.calendar.name}, events: ${keepSyncEventCount}), disabling ${duplicates.length} duplicates`); // Disable or delete duplicate syncs for (const duplicate of duplicates) { if (duplicate.syncEnabled) { // Disable the duplicate sync console.log(`[AGENDA] Disabling duplicate sync ${duplicate.id} (calendar: ${duplicate.calendar.name})`); await prisma.calendarSync.update({ where: { id: duplicate.id }, data: { syncEnabled: false } }); } // Delete the calendar if it has no events const eventCount = await prisma.event.count({ where: { calendarId: duplicate.calendarId } }); if (eventCount === 0) { console.log(`[AGENDA] Deleting empty calendar ${duplicate.calendar.name} (${duplicate.calendarId})`); await prisma.calendar.delete({ where: { id: duplicate.calendarId } }); } } } else if (enabledSyncs.length === 1 && syncs.length > 1) { // There's only one enabled sync but multiple disabled ones - this is fine, don't touch anything console.log(`[AGENDA] Found 1 enabled sync and ${syncs.length - 1} disabled syncs for ${key} - no cleanup needed`); } } } } // Auto-sync Microsoft calendars if needed (background, don't block page load) const microsoftSyncConfigs = await prisma.calendarSync.findMany({ where: { provider: 'microsoft', syncEnabled: true, calendar: { userId: session?.user?.id || '' } } }); console.log(`[AGENDA] Found ${microsoftSyncConfigs.length} Microsoft sync configs`); // Trigger sync for Microsoft calendars that need it (async, don't wait) for (const syncConfig of microsoftSyncConfigs) { // For Microsoft, use a more frequent check (1 minute) for better reactivity // This allows new events to appear faster without overloading the API const microsoftMinSyncInterval = 1; // minutes (reduced from 2 to 1 for faster sync) const minutesSinceLastSync = syncConfig.lastSyncAt ? (Date.now() - syncConfig.lastSyncAt.getTime()) / (1000 * 60) : Infinity; // Sync if never synced, or if enough time has passed (use minimum of 1 min or configured frequency) // Also sync if last sync had an error or if calendar has no events (might be a new setup) const calendar = await prisma.calendar.findUnique({ where: { id: syncConfig.calendarId }, include: { _count: { select: { events: true } } } }); const hasEvents = (calendar?._count?.events || 0) > 0; const hasError = !!syncConfig.lastSyncError; const needsSync = !syncConfig.lastSyncAt || minutesSinceLastSync >= Math.min(microsoftMinSyncInterval, syncConfig.syncFrequency) || hasError || // Force sync if there was an error (!hasEvents && syncConfig.lastSyncAt); // Force sync if calendar has no events but was synced before console.log(`[AGENDA] Microsoft sync config ${syncConfig.id}: lastSyncAt=${syncConfig.lastSyncAt}, minutesSinceLastSync=${minutesSinceLastSync.toFixed(1)}, needsSync=${needsSync}, syncFrequency=${syncConfig.syncFrequency}, hasEvents=${hasEvents}, hasError=${hasError}`); if (needsSync) { console.log(`[AGENDA] Triggering background sync for Microsoft calendar ${syncConfig.id}`); // Trigger sync in background (don't await to avoid blocking page load) // The sync will update the database, and the next page load will show the events // Use forceSync=true because we've already checked that sync is needed import('@/lib/services/microsoft-calendar-sync').then(({ syncMicrosoftCalendar }) => { syncMicrosoftCalendar(syncConfig.id, true).then((result) => { console.log(`[AGENDA] Microsoft sync completed:`, { calendarSyncId: syncConfig.id, calendarId: syncConfig.calendarId, synced: result.synced, created: result.created, updated: result.updated, deleted: result.deleted, }); // Verify events were created by checking the database prisma.event.count({ where: { calendarId: syncConfig.calendarId } }).then((count) => { console.log(`[AGENDA] Total events in calendar ${syncConfig.calendarId} after sync: ${count}`); }).catch((err) => { console.error('[AGENDA] Error counting events:', err); }); }).catch((error) => { console.error('[AGENDA] Background sync failed for Microsoft calendar', { calendarSyncId: syncConfig.id, calendarId: syncConfig.calendarId, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); }); }); } else { console.log(`[AGENDA] Microsoft sync skipped - too soon since last sync (${minutesSinceLastSync.toFixed(1)} min < ${Math.min(microsoftMinSyncInterval, syncConfig.syncFrequency)} min)`); } } // Refresh calendars after auto-setup and cleanup // Exclude "Privée" and "Default" calendars that are not synced // IMPORTANT: Include all "Privée"/"Default" calendars that have ANY syncConfig (enabled or disabled) // We'll filter by syncEnabled later calendars = await prisma.calendar.findMany({ where: { userId: session?.user?.id || '', OR: [ // Keep calendars that are not "Privée" or "Default" { name: { notIn: ["Privée", "Default"] } }, // Or keep "Privée"/"Default" calendars that have ANY sync config (we'll filter by enabled later) { AND: [ { name: { in: ["Privée", "Default"] } }, { syncConfig: { isNot: null } } ] } ] }, include: { events: { orderBy: { start: 'asc' } }, mission: { include: { missionUsers: true } }, syncConfig: { include: { mailCredential: { select: { id: true, email: true, display_name: true, } } } } } }); // No default calendar creation - only synced calendars from courrier // Filter out "Privée" and "Default" calendars that don't have active sync calendars = calendars.filter(cal => { const isPrivateOrDefault = cal.name === "Privée" || cal.name === "Default"; const hasActiveSync = cal.syncConfig?.syncEnabled === true && cal.syncConfig?.mailCredential; // Exclude "Privée"/"Default" calendars that are not actively synced if (isPrivateOrDefault && !hasActiveSync) { console.log(`[AGENDA] Filtering out calendar ${cal.name} (${cal.id}): syncEnabled=${cal.syncConfig?.syncEnabled}, hasMailCredential=${!!cal.syncConfig?.mailCredential}`); return false; } return true; }); // Debug: Log all calendars with syncConfig to see what we have const calendarsWithSync = calendars.filter(cal => cal.syncConfig); console.log(`[AGENDA] Total calendars with syncConfig: ${calendarsWithSync.length}`); calendarsWithSync.forEach(cal => { const eventCount = cal.events?.length || 0; console.log(`[AGENDA] Calendar: ${cal.name}, provider: ${cal.syncConfig?.provider}, syncEnabled: ${cal.syncConfig?.syncEnabled}, hasMailCredential: ${!!cal.syncConfig?.mailCredential}, events: ${eventCount}`); if (cal.syncConfig?.provider === 'microsoft') { // Log all Microsoft events with details console.log(`[AGENDA] Microsoft calendar ${cal.id} events (${eventCount}):`, cal.events.map(e => ({ id: e.id, title: e.title, start: e.start, end: e.end, isAllDay: e.isAllDay, description: e.description ? e.description.substring(0, 50) : null }))); // Also check directly in DB to see if there are more events prisma.event.count({ where: { calendarId: cal.id } }).then((dbCount) => { if (dbCount !== eventCount) { console.log(`[AGENDA] WARNING: Calendar ${cal.id} has ${eventCount} events in query but ${dbCount} in DB`); } }).catch((err) => { console.error('[AGENDA] Error counting events in DB:', err); }); } }); // Sort calendars: "Mon Calendrier" first, then synced, then groups, then missions calendars = calendars.sort((a, b) => { const aIsMonCalendrier = a.name === "Mon Calendrier"; const bIsMonCalendrier = b.name === "Mon Calendrier"; const aIsSynced = a.syncConfig?.syncEnabled && a.syncConfig?.mailCredential; const bIsSynced = b.syncConfig?.syncEnabled && b.syncConfig?.mailCredential; const aIsGroup = a.name?.startsWith("Groupe:"); const bIsGroup = b.name?.startsWith("Groupe:"); const aIsMission = a.name?.startsWith("Mission:"); const bIsMission = b.name?.startsWith("Mission:"); // "Mon Calendrier" always first if (aIsMonCalendrier && !bIsMonCalendrier) return -1; if (!aIsMonCalendrier && bIsMonCalendrier) return 1; // Synced calendars second if (aIsSynced && !bIsSynced) return -1; if (!aIsSynced && bIsSynced) return 1; // Groups third if (aIsGroup && !bIsGroup && !bIsSynced) return -1; if (!aIsGroup && bIsGroup && !aIsSynced) return 1; // Missions fourth if (aIsMission && !bIsMission && !bIsGroup && !bIsSynced) return -1; if (!aIsMission && bIsMission && !aIsGroup && !aIsSynced) return 1; // Same type, sort by name return (a.name || '').localeCompare(b.name || ''); }); const now = new Date(); const nextWeek = add(now, { days: 7 }); const upcomingEvents = calendars.flatMap(cal => cal.events.filter(event => new Date(event.start) >= now && new Date(event.start) <= nextWeek ) ).sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); // Calculate statistics const totalEvents = calendars.flatMap(cal => cal.events).length; const totalMeetingHours = calendars .flatMap(cal => cal.events) .reduce((total, event) => { const start = new Date(event.start); const end = new Date(event.end); const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); return total + (isNaN(hours) ? 0 : hours); }, 0); return (
); }