Agenda Sync refactor

This commit is contained in:
alma 2026-01-14 13:47:36 +01:00
parent 3b6d85a1cc
commit b67143c2e8
8 changed files with 1037 additions and 0 deletions

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

474
lib/services/caldav-sync.ts Normal file
View File

@ -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<WebDAVClient> {
// 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<CalDAVCalendar[]> {
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: `<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<c:calendar-color />
</d:prop>
</d:propfind>`,
});
// 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[^>]*>([^<]+)<\/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[^>]*>([^<]+)<\/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<CalDAVEvent[]> {
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 = `<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag />
<C:calendar-data />
</D:prop>
${start && end ? `
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="${start}" end="${end}" />
</C:comp-filter>
</C:comp-filter>
</C:filter>` : ''}
</C:calendar-query>`;
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<string, typeof existingEvents[0]>();
// 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;
}
}

View File

@ -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<void> {
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;
}
}

View File

@ -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",

View File

@ -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])
@ -188,3 +190,26 @@ model MissionUser {
@@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])
}

78
scripts/sync-calendars.js Normal file
View File

@ -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();