diff --git a/app/api/calendars/sync/discover/route.ts b/app/api/calendars/sync/discover/route.ts new file mode 100644 index 0000000..bfc6c72 --- /dev/null +++ b/app/api/calendars/sync/discover/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/options"; +import { prisma } from "@/lib/prisma"; +import { discoverInfomaniakCalendars } from "@/lib/services/caldav-sync"; +import { logger } from "@/lib/logger"; + +/** + * Discover CalDAV calendars for an Infomaniak email account + * POST /api/calendars/sync/discover + */ +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const { mailCredentialId } = await req.json(); + + if (!mailCredentialId) { + return NextResponse.json( + { error: "mailCredentialId est requis" }, + { status: 400 } + ); + } + + // Get mail credentials + const mailCreds = await prisma.mailCredentials.findFirst({ + where: { + id: mailCredentialId, + userId: session.user.id, + }, + }); + + if (!mailCreds) { + return NextResponse.json( + { error: "Credentials non trouvés" }, + { status: 404 } + ); + } + + // Check if it's an Infomaniak account + if (!mailCreds.host.includes('infomaniak')) { + return NextResponse.json( + { error: "Ce compte n'est pas un compte Infomaniak" }, + { status: 400 } + ); + } + + if (!mailCreds.password) { + return NextResponse.json( + { error: "Mot de passe requis pour la synchronisation CalDAV" }, + { status: 400 } + ); + } + + // Discover calendars + const calendars = await discoverInfomaniakCalendars( + mailCreds.email, + mailCreds.password + ); + + logger.info('Calendars discovered', { + userId: session.user.id, + email: mailCreds.email, + calendarsCount: calendars.length, + }); + + return NextResponse.json({ calendars }); + } catch (error) { + logger.error('Error discovering calendars', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { + error: "Erreur lors de la découverte des calendriers", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/app/api/calendars/sync/job/route.ts b/app/api/calendars/sync/job/route.ts new file mode 100644 index 0000000..b4c3857 --- /dev/null +++ b/app/api/calendars/sync/job/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/options"; +import { runCalendarSyncJob } from "@/lib/services/calendar-sync-job"; +import { logger } from "@/lib/logger"; + +/** + * Trigger calendar sync job manually (admin only or cron) + * POST /api/calendars/sync/job + */ +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + // Check for API key in header (for cron jobs) + const apiKey = req.headers.get('x-api-key'); + const isCronRequest = apiKey === process.env.CALENDAR_SYNC_API_KEY; + + if (!session?.user?.id && !isCronRequest) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + // If authenticated, check if user is admin + if (session?.user?.id && !session.user.role?.includes('ROLE_Admin') && !isCronRequest) { + return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); + } + + logger.info('Manual calendar sync job triggered', { + userId: session?.user?.id, + isCronRequest, + }); + + // Run sync job + await runCalendarSyncJob(); + + return NextResponse.json({ success: true, message: "Synchronisation terminée" }); + } catch (error) { + logger.error('Error running calendar sync job', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { + error: "Erreur lors de la synchronisation", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/app/api/calendars/sync/route.ts b/app/api/calendars/sync/route.ts new file mode 100644 index 0000000..25ed354 --- /dev/null +++ b/app/api/calendars/sync/route.ts @@ -0,0 +1,248 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/options"; +import { prisma } from "@/lib/prisma"; +import { syncInfomaniakCalendar } from "@/lib/services/caldav-sync"; +import { logger } from "@/lib/logger"; + +/** + * Create a calendar sync configuration + * POST /api/calendars/sync + */ +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const { calendarId, mailCredentialId, externalCalendarUrl, externalCalendarId, syncFrequency } = await req.json(); + + if (!calendarId || !mailCredentialId || !externalCalendarUrl) { + return NextResponse.json( + { error: "calendarId, mailCredentialId et externalCalendarUrl sont requis" }, + { status: 400 } + ); + } + + // Verify calendar belongs to user + const calendar = await prisma.calendar.findFirst({ + where: { + id: calendarId, + userId: session.user.id, + }, + }); + + if (!calendar) { + return NextResponse.json( + { error: "Calendrier non trouvé" }, + { status: 404 } + ); + } + + // Verify mail credentials belong to user + const mailCreds = await prisma.mailCredentials.findFirst({ + where: { + id: mailCredentialId, + userId: session.user.id, + }, + }); + + if (!mailCreds) { + return NextResponse.json( + { error: "Credentials non trouvés" }, + { status: 404 } + ); + } + + // Create or update sync configuration + const syncConfig = await prisma.calendarSync.upsert({ + where: { calendarId }, + create: { + calendarId, + mailCredentialId, + provider: 'infomaniak', + externalCalendarId: externalCalendarId || null, + externalCalendarUrl, + syncEnabled: true, + syncFrequency: syncFrequency || 15, + }, + update: { + mailCredentialId, + externalCalendarId: externalCalendarId || null, + externalCalendarUrl, + syncFrequency: syncFrequency || 15, + syncEnabled: true, + }, + }); + + // Trigger initial sync + try { + await syncInfomaniakCalendar(syncConfig.id, true); + } catch (syncError) { + logger.error('Error during initial sync', { + syncConfigId: syncConfig.id, + error: syncError instanceof Error ? syncError.message : String(syncError), + }); + // Don't fail the request if sync fails, just log it + } + + return NextResponse.json({ syncConfig }); + } catch (error) { + logger.error('Error creating calendar sync', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { + error: "Erreur lors de la création de la synchronisation", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + +/** + * Trigger manual sync for a calendar + * PUT /api/calendars/sync + */ +export async function PUT(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const { calendarSyncId } = await req.json(); + + if (!calendarSyncId) { + return NextResponse.json( + { error: "calendarSyncId est requis" }, + { status: 400 } + ); + } + + // Verify sync config belongs to user + const syncConfig = await prisma.calendarSync.findUnique({ + where: { id: calendarSyncId }, + include: { + calendar: true, + }, + }); + + if (!syncConfig || syncConfig.calendar.userId !== session.user.id) { + return NextResponse.json( + { error: "Synchronisation non trouvée" }, + { status: 404 } + ); + } + + // Trigger sync + const result = await syncInfomaniakCalendar(calendarSyncId, true); + + return NextResponse.json({ success: true, result }); + } catch (error) { + logger.error('Error triggering calendar sync', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { + error: "Erreur lors de la synchronisation", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + +/** + * Get sync status for user calendars + * GET /api/calendars/sync + */ +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const syncConfigs = await prisma.calendarSync.findMany({ + where: { + calendar: { + userId: session.user.id, + }, + }, + include: { + calendar: true, + mailCredential: { + select: { + id: true, + email: true, + display_name: true, + }, + }, + }, + }); + + return NextResponse.json({ syncConfigs }); + } catch (error) { + logger.error('Error fetching sync configs', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ); + } +} + +/** + * Delete sync configuration + * DELETE /api/calendars/sync + */ +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + + const { calendarSyncId } = await req.json(); + + if (!calendarSyncId) { + return NextResponse.json( + { error: "calendarSyncId est requis" }, + { status: 400 } + ); + } + + // Verify sync config belongs to user + const syncConfig = await prisma.calendarSync.findUnique({ + where: { id: calendarSyncId }, + include: { + calendar: true, + }, + }); + + if (!syncConfig || syncConfig.calendar.userId !== session.user.id) { + return NextResponse.json( + { error: "Synchronisation non trouvée" }, + { status: 404 } + ); + } + + await prisma.calendarSync.delete({ + where: { id: calendarSyncId }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Error deleting sync config', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ); + } +} diff --git a/lib/services/caldav-sync.ts b/lib/services/caldav-sync.ts new file mode 100644 index 0000000..b31d92d --- /dev/null +++ b/lib/services/caldav-sync.ts @@ -0,0 +1,474 @@ +import { createClient, WebDAVClient } from 'webdav'; +import { prisma } from '@/lib/prisma'; +import { logger } from '@/lib/logger'; + +export interface CalDAVCalendar { + id: string; + name: string; + url: string; + color?: string; +} + +export interface CalDAVEvent { + uid: string; + summary: string; + description?: string; + start: Date; + end: Date; + location?: string; + allDay: boolean; +} + +/** + * Get CalDAV client for Infomaniak account + */ +export async function getInfomaniakCalDAVClient( + email: string, + password: string +): Promise { + // Infomaniak CalDAV base URL + const baseUrl = 'https://sync.infomaniak.com/caldav'; + + const client = createClient(baseUrl, { + username: email, + password: password, + }); + + return client; +} + +/** + * Discover calendars available for an Infomaniak account + */ +export async function discoverInfomaniakCalendars( + email: string, + password: string +): Promise { + try { + const client = await getInfomaniakCalDAVClient(email, password); + + // List all calendars using PROPFIND + const items = await client.getDirectoryContents('/'); + + const calendars: CalDAVCalendar[] = []; + + for (const item of items) { + if (item.type === 'directory' && item.filename !== '/') { + // Get calendar properties + try { + const props = await client.customRequest(item.filename, { + method: 'PROPFIND', + headers: { + Depth: '0', + 'Content-Type': 'application/xml', + }, + data: ` + + + + + +`, + }); + + // Parse XML response to extract calendar name and color + const displayName = extractDisplayName(props.data); + const color = extractCalendarColor(props.data); + + calendars.push({ + id: item.filename.replace(/^\//, '').replace(/\/$/, ''), + name: displayName || item.basename || 'Calendrier', + url: item.filename, + color: color, + }); + } catch (error) { + logger.error('Error fetching calendar properties', { + calendar: item.filename, + error: error instanceof Error ? error.message : String(error), + }); + // Still add the calendar with default name + calendars.push({ + id: item.filename.replace(/^\//, '').replace(/\/$/, ''), + name: item.basename || 'Calendrier', + url: item.filename, + }); + } + } + } + + return calendars; + } catch (error) { + logger.error('Error discovering Infomaniak calendars', { + email, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Extract display name from PROPFIND XML response + */ +function extractDisplayName(xmlData: string): string | null { + try { + const match = xmlData.match(/]*>([^<]+)<\/d:displayname>/i); + return match ? match[1] : null; + } catch { + return null; + } +} + +/** + * Extract calendar color from PROPFIND XML response + */ +function extractCalendarColor(xmlData: string): string | undefined { + try { + const match = xmlData.match(/]*>([^<]+)<\/c:calendar-color>/i); + return match ? match[1] : undefined; + } catch { + return undefined; + } +} + +/** + * Fetch events from a CalDAV calendar + */ +export async function fetchCalDAVEvents( + email: string, + password: string, + calendarUrl: string, + startDate?: Date, + endDate?: Date +): Promise { + try { + const client = await getInfomaniakCalDAVClient(email, password); + + // Build calendar query URL + const queryUrl = calendarUrl.endsWith('/') ? calendarUrl : `${calendarUrl}/`; + + // Build CALDAV query XML + const start = startDate ? startDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : ''; + const end = endDate ? endDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : ''; + + const queryXml = ` + + + + + + ${start && end ? ` + + + + + + + ` : ''} +`; + + const response = await client.customRequest(queryUrl, { + method: 'REPORT', + headers: { + Depth: '1', + 'Content-Type': 'application/xml', + }, + data: queryXml, + }); + + // Parse iCalendar data from response + const events = parseICalendarEvents(response.data); + + return events; + } catch (error) { + logger.error('Error fetching CalDAV events', { + email, + calendarUrl, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Parse iCalendar format events from CalDAV response + */ +function parseICalendarEvents(icalData: string): CalDAVEvent[] { + const events: CalDAVEvent[] = []; + + // Split by BEGIN:VEVENT + const eventBlocks = icalData.split(/BEGIN:VEVENT/gi); + + for (const block of eventBlocks.slice(1)) { // Skip first empty block + try { + const event = parseICalendarEvent(block); + if (event) { + events.push(event); + } + } catch (error) { + logger.error('Error parsing iCalendar event', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return events; +} + +/** + * Parse a single iCalendar event block + */ +function parseICalendarEvent(block: string): CalDAVEvent | null { + const lines = block.split(/\r?\n/); + + let uid: string | null = null; + let summary: string | null = null; + let description: string | undefined; + let dtstart: string | null = null; + let dtend: string | null = null; + let location: string | undefined; + let allDay = false; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + + // Handle line continuation (lines starting with space) + while (i + 1 < lines.length && lines[i + 1].startsWith(' ')) { + line += lines[i + 1].substring(1); + i++; + } + + if (line.startsWith('UID:')) { + uid = line.substring(4).trim(); + } else if (line.startsWith('SUMMARY:')) { + summary = line.substring(8).trim(); + } else if (line.startsWith('DESCRIPTION:')) { + description = line.substring(12).trim(); + } else if (line.startsWith('DTSTART')) { + const match = line.match(/DTSTART(?:;VALUE=DATE)?:([^;]+)/); + if (match) { + dtstart = match[1]; + allDay = line.includes('VALUE=DATE'); + } + } else if (line.startsWith('DTEND')) { + const match = line.match(/DTEND(?:;VALUE=DATE)?:([^;]+)/); + if (match) { + dtend = match[1]; + } + } else if (line.startsWith('LOCATION:')) { + location = line.substring(9).trim(); + } + } + + if (!uid || !summary || !dtstart || !dtend) { + return null; + } + + // Parse dates + const start = parseICalendarDate(dtstart, allDay); + const end = parseICalendarDate(dtend, allDay); + + if (!start || !end) { + return null; + } + + return { + uid, + summary, + description, + start, + end, + location, + allDay, + }; +} + +/** + * Parse iCalendar date format (YYYYMMDDTHHmmssZ or YYYYMMDD) + */ +function parseICalendarDate(dateStr: string, allDay: boolean): Date | null { + try { + if (allDay) { + // Format: YYYYMMDD + const year = parseInt(dateStr.substring(0, 4)); + const month = parseInt(dateStr.substring(4, 6)) - 1; // Month is 0-indexed + const day = parseInt(dateStr.substring(6, 8)); + return new Date(year, month, day); + } else { + // Format: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss + const cleanDate = dateStr.replace(/[TZ]/g, ''); + const year = parseInt(cleanDate.substring(0, 4)); + const month = parseInt(cleanDate.substring(4, 6)) - 1; + const day = parseInt(cleanDate.substring(6, 8)); + const hour = cleanDate.length > 8 ? parseInt(cleanDate.substring(9, 11)) : 0; + const minute = cleanDate.length > 10 ? parseInt(cleanDate.substring(11, 13)) : 0; + const second = cleanDate.length > 12 ? parseInt(cleanDate.substring(13, 15)) : 0; + + const date = new Date(year, month, day, hour, minute, second); + + // If timezone is UTC (Z), convert to local time + if (dateStr.includes('Z')) { + return new Date(date.getTime() - date.getTimezoneOffset() * 60000); + } + + return date; + } + } catch (error) { + logger.error('Error parsing iCalendar date', { + dateStr, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Sync events from Infomaniak CalDAV calendar to local Prisma calendar + */ +export async function syncInfomaniakCalendar( + calendarSyncId: string, + forceSync: boolean = false +): Promise<{ synced: number; created: number; updated: number; deleted: number }> { + try { + const syncConfig = await prisma.calendarSync.findUnique({ + where: { id: calendarSyncId }, + include: { + calendar: true, + mailCredential: true, + }, + }); + + if (!syncConfig || !syncConfig.syncEnabled) { + throw new Error('Calendar sync not enabled or not found'); + } + + if (!syncConfig.mailCredential) { + throw new Error('Mail credentials not found for calendar sync'); + } + + const creds = syncConfig.mailCredential; + + // Check if sync is needed (based on syncFrequency) + if (!forceSync && syncConfig.lastSyncAt) { + const minutesSinceLastSync = (Date.now() - syncConfig.lastSyncAt.getTime()) / (1000 * 60); + if (minutesSinceLastSync < syncConfig.syncFrequency) { + logger.debug('Sync skipped - too soon since last sync', { + calendarSyncId, + minutesSinceLastSync, + syncFrequency: syncConfig.syncFrequency, + }); + return { synced: 0, created: 0, updated: 0, deleted: 0 }; + } + } + + if (!creds.password) { + throw new Error('Password required for Infomaniak CalDAV sync'); + } + + // Fetch events from CalDAV + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 1); // Sync last month to next 3 months + const endDate = new Date(); + endDate.setMonth(endDate.getMonth() + 3); + + const caldavEvents = await fetchCalDAVEvents( + creds.email, + creds.password, + syncConfig.externalCalendarUrl || '', + startDate, + endDate + ); + + // Get existing events in local calendar + const existingEvents = await prisma.event.findMany({ + where: { + calendarId: syncConfig.calendarId, + }, + }); + + // Create a map of existing events by external UID + const existingEventsMap = new Map(); + // Store events that have external UID in metadata (we'll need to add this field) + // For now, we'll match by title and date + + let created = 0; + let updated = 0; + let deleted = 0; + + // Sync events: create or update + for (const caldavEvent of caldavEvents) { + // Try to find existing event by matching title and start date + const existingEvent = existingEvents.find( + (e) => + e.title === caldavEvent.summary && + Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime()) < 60000 // Within 1 minute + ); + + const eventData = { + title: caldavEvent.summary, + description: caldavEvent.description || null, + start: caldavEvent.start, + end: caldavEvent.end, + location: caldavEvent.location || null, + isAllDay: caldavEvent.allDay, + calendarId: syncConfig.calendarId, + userId: syncConfig.calendar.userId, + }; + + if (existingEvent) { + // Update existing event + await prisma.event.update({ + where: { id: existingEvent.id }, + data: eventData, + }); + updated++; + } else { + // Create new event + await prisma.event.create({ + data: eventData, + }); + created++; + } + } + + // Update sync timestamp + await prisma.calendarSync.update({ + where: { id: calendarSyncId }, + data: { + lastSyncAt: new Date(), + lastSyncError: null, + }, + }); + + logger.info('Calendar sync completed', { + calendarSyncId, + calendarId: syncConfig.calendarId, + synced: caldavEvents.length, + created, + updated, + }); + + return { + synced: caldavEvents.length, + created, + updated, + deleted, + }; + } catch (error) { + logger.error('Error syncing calendar', { + calendarSyncId, + error: error instanceof Error ? error.message : String(error), + }); + + // Update sync error + await prisma.calendarSync.update({ + where: { id: calendarSyncId }, + data: { + lastSyncError: error instanceof Error ? error.message : String(error), + }, + }).catch(() => { + // Ignore errors updating sync error + }); + + throw error; + } +} diff --git a/lib/services/calendar-sync-job.ts b/lib/services/calendar-sync-job.ts new file mode 100644 index 0000000..dd61c5f --- /dev/null +++ b/lib/services/calendar-sync-job.ts @@ -0,0 +1,79 @@ +import { prisma } from '@/lib/prisma'; +import { syncInfomaniakCalendar } from './caldav-sync'; +import { logger } from '@/lib/logger'; + +/** + * Run periodic sync for all enabled calendar syncs + * This should be called by a cron job or scheduled task + */ +export async function runCalendarSyncJob(): Promise { + try { + logger.info('Starting calendar sync job'); + + // Get all enabled sync configurations that need syncing + const syncConfigs = await prisma.calendarSync.findMany({ + where: { + syncEnabled: true, + }, + include: { + calendar: true, + mailCredential: true, + }, + }); + + logger.info('Found sync configurations', { + count: syncConfigs.length, + }); + + const results = { + total: syncConfigs.length, + successful: 0, + failed: 0, + skipped: 0, + }; + + for (const syncConfig of syncConfigs) { + try { + // Check if sync is needed + if (syncConfig.lastSyncAt) { + const minutesSinceLastSync = + (Date.now() - syncConfig.lastSyncAt.getTime()) / (1000 * 60); + if (minutesSinceLastSync < syncConfig.syncFrequency) { + logger.debug('Sync skipped - too soon', { + calendarSyncId: syncConfig.id, + minutesSinceLastSync, + syncFrequency: syncConfig.syncFrequency, + }); + results.skipped++; + continue; + } + } + + // Sync based on provider + if (syncConfig.provider === 'infomaniak') { + await syncInfomaniakCalendar(syncConfig.id, false); + results.successful++; + } else { + logger.warn('Unknown sync provider', { + calendarSyncId: syncConfig.id, + provider: syncConfig.provider, + }); + results.skipped++; + } + } catch (error) { + logger.error('Error syncing calendar', { + calendarSyncId: syncConfig.id, + error: error instanceof Error ? error.message : String(error), + }); + results.failed++; + } + } + + logger.info('Calendar sync job completed', results); + } catch (error) { + logger.error('Error running calendar sync job', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/package.json b/package.json index 982c913..ed28bdf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "start": "next start", "lint": "next lint", "sync-users": "node scripts/sync-users.js", + "sync-calendars": "node scripts/sync-calendars.js", "migrate:dev": "prisma migrate dev", "migrate:deploy": "prisma migrate deploy", "migrate:status": "prisma migrate status", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 37cb70d..75a1bd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model Calendar { events Event[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) mission Mission? @relation(fields: [missionId], references: [id], onDelete: Cascade) + syncConfig CalendarSync? // Optional: sync configuration for external calendars @@index([userId]) @@index([missionId]) @@ -90,6 +91,7 @@ model MailCredentials { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) + calendarSyncs CalendarSync[] // Calendars synced from this email account @@unique([userId, email]) @@index([userId]) @@ -187,4 +189,27 @@ model MissionUser { @@unique([missionId, userId, role]) @@index([missionId]) @@index([userId]) +} + +// Calendar synchronization configuration for external calendars (CalDAV, etc.) +model CalendarSync { + id String @id @default(uuid()) + calendarId String @unique // Link to local calendar + mailCredentialId String? // Link to MailCredentials for Infomaniak accounts + provider String // "infomaniak", "microsoft", "google", etc. + externalCalendarId String? // ID of the calendar in the external system + externalCalendarUrl String? // Full CalDAV URL for the calendar + syncEnabled Boolean @default(true) + lastSyncAt DateTime? + syncFrequency Int @default(15) // minutes between syncs + lastSyncError String? // Store last error message if sync failed + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade) + mailCredential MailCredentials? @relation(fields: [mailCredentialId], references: [id], onDelete: Cascade) + + @@index([calendarId]) + @@index([mailCredentialId]) + @@index([provider]) } \ No newline at end of file diff --git a/scripts/sync-calendars.js b/scripts/sync-calendars.js new file mode 100644 index 0000000..e7fd22e --- /dev/null +++ b/scripts/sync-calendars.js @@ -0,0 +1,78 @@ +/** + * Calendar sync job script + * Run this periodically (e.g., every 15 minutes) to sync external calendars + * + * Usage: + * node scripts/sync-calendars.js + * + * Or via cron: + * */15 * * * * cd /path/to/NeahStable && node scripts/sync-calendars.js >> /var/log/calendar-sync.log 2>&1 + * + * Or call the API endpoint: + * curl -X POST http://localhost:3000/api/calendars/sync/job -H "x-api-key: YOUR_API_KEY" + */ + +// For now, this script calls the API endpoint +// In production, you can use this or call the API directly + +const http = require('http'); +const https = require('https'); + +const API_URL = process.env.CALENDAR_SYNC_API_URL || 'http://localhost:3000'; +const API_KEY = process.env.CALENDAR_SYNC_API_KEY || ''; + +async function callSyncAPI() { + return new Promise((resolve, reject) => { + const url = new URL(`${API_URL}/api/calendars/sync/job`); + const client = url.protocol === 'https:' ? https : http; + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': API_KEY, + }, + }; + + const req = client.request(url, options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + console.log(`[${new Date().toISOString()}] Sync completed successfully`); + resolve(JSON.parse(data)); + } else { + console.error(`[${new Date().toISOString()}] Sync failed:`, data); + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', (error) => { + console.error(`[${new Date().toISOString()}] Request error:`, error); + reject(error); + }); + + req.end(); + }); +} + +async function runSync() { + try { + console.log(`[${new Date().toISOString()}] Starting calendar sync job`); + + await callSyncAPI(); + + console.log(`[${new Date().toISOString()}] Calendar sync job completed successfully`); + process.exit(0); + } catch (error) { + console.error(`[${new Date().toISOString()}] Error running calendar sync job:`, error.message); + process.exit(1); + } +} + +runSync();