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",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"sync-users": "node scripts/sync-users.js",
|
"sync-users": "node scripts/sync-users.js",
|
||||||
|
"sync-calendars": "node scripts/sync-calendars.js",
|
||||||
"migrate:dev": "prisma migrate dev",
|
"migrate:dev": "prisma migrate dev",
|
||||||
"migrate:deploy": "prisma migrate deploy",
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
"migrate:status": "prisma migrate status",
|
"migrate:status": "prisma migrate status",
|
||||||
|
|||||||
@ -39,6 +39,7 @@ model Calendar {
|
|||||||
events Event[]
|
events Event[]
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
mission Mission? @relation(fields: [missionId], references: [id], onDelete: Cascade)
|
mission Mission? @relation(fields: [missionId], references: [id], onDelete: Cascade)
|
||||||
|
syncConfig CalendarSync? // Optional: sync configuration for external calendars
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([missionId])
|
@@index([missionId])
|
||||||
@ -90,6 +91,7 @@ model MailCredentials {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
calendarSyncs CalendarSync[] // Calendars synced from this email account
|
||||||
|
|
||||||
@@unique([userId, email])
|
@@unique([userId, email])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@ -188,3 +190,26 @@ model MissionUser {
|
|||||||
@@index([missionId])
|
@@index([missionId])
|
||||||
@@index([userId])
|
@@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