Agenda Sync refactor
This commit is contained in:
parent
3b6d85a1cc
commit
b67143c2e8
83
app/api/calendars/sync/discover/route.ts
Normal file
83
app/api/calendars/sync/discover/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/calendars/sync/job/route.ts
Normal file
49
app/api/calendars/sync/job/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
248
app/api/calendars/sync/route.ts
Normal file
248
app/api/calendars/sync/route.ts
Normal 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
474
lib/services/caldav-sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
79
lib/services/calendar-sync-job.ts
Normal file
79
lib/services/calendar-sync-job.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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])
|
||||
}
|
||||
78
scripts/sync-calendars.js
Normal file
78
scripts/sync-calendars.js
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user