Agenda refactor

This commit is contained in:
alma 2026-01-15 12:29:19 +01:00
parent e50af5450d
commit 1d2a72ce8c
3 changed files with 114 additions and 49 deletions

View File

@ -235,7 +235,44 @@ export default async function CalendarPage() {
// If sync exists but is disabled, check if it's due to invalid credentials
// Don't re-enable if the last error was 401 (invalid credentials)
if (existingSync) {
console.log(`[AGENDA] Found existing sync for Infomaniak account ${account.email}: syncId=${existingSync.id}, calendarId=${existingSync.calendarId}, syncEnabled=${existingSync.syncEnabled}, hasCalendar=${!!existingSync.calendar}`);
console.log(`[AGENDA] Found existing sync for Infomaniak account ${account.email}: syncId=${existingSync.id}, calendarId=${existingSync.calendarId}, syncEnabled=${existingSync.syncEnabled}, hasCalendar=${!!existingSync.calendar}, externalCalendarUrl=${existingSync.externalCalendarUrl}`);
// Fix invalid calendar URLs (like /principals)
if (existingSync.externalCalendarUrl === '/principals' || !existingSync.externalCalendarUrl || existingSync.externalCalendarUrl === '/') {
console.log(`[AGENDA] Invalid calendar URL detected (${existingSync.externalCalendarUrl}), attempting to rediscover...`);
try {
const { discoverInfomaniakCalendars } = await import('@/lib/services/caldav-sync');
const externalCalendars = await discoverInfomaniakCalendars(
account.email,
account.password!
);
if (externalCalendars.length > 0) {
const mainCalendar = externalCalendars[0];
console.log(`[AGENDA] Updating sync with correct calendar URL: ${mainCalendar.url}`);
await prisma.calendarSync.update({
where: { id: existingSync.id },
data: {
externalCalendarId: mainCalendar.id,
externalCalendarUrl: mainCalendar.url,
lastSyncError: null, // Clear error since we're fixing the URL
}
});
// Reload the sync config
const updatedSync = await prisma.calendarSync.findUnique({
where: { id: existingSync.id },
include: { calendar: true }
});
if (updatedSync) {
existingSync = updatedSync;
}
} else {
console.log(`[AGENDA] No calendars found during rediscovery for ${account.email}`);
}
} catch (rediscoverError) {
console.error(`[AGENDA] Error rediscovering calendars:`, rediscoverError);
}
}
// Check if calendar still exists
if (!existingSync.calendar) {

View File

@ -53,46 +53,55 @@ export async function discoverInfomaniakCalendars(
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" ?>
// Skip non-directories, root, and special directories like /principals
if (item.type !== 'directory' || item.filename === '/' || item.filename === '/principals') {
continue;
}
// Get calendar properties to verify it's actually a calendar
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:resourcetype />
</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,
// Check if this is actually a calendar (has <c:calendar> in resourcetype)
const isCalendar = props.data && props.data.includes('<c:calendar');
if (!isCalendar) {
logger.debug('Skipping non-calendar directory', {
filename: item.filename,
});
continue;
}
// 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),
});
// Don't add calendars that fail property fetch - they might not be calendars
}
}
@ -154,6 +163,11 @@ export async function fetchCalDAVEvents(
endDate?: Date
): Promise<CalDAVEvent[]> {
try {
// Validate calendar URL - must not be /principals or other non-calendar paths
if (!calendarUrl || calendarUrl === '/principals' || calendarUrl === '/') {
throw new Error(`Invalid calendar URL: ${calendarUrl}. This is not a calendar directory.`);
}
const client = await getInfomaniakCalDAVClient(email, password);
// Build calendar query URL
@ -188,6 +202,11 @@ export async function fetchCalDAVEvents(
data: queryXml,
});
// Validate response data exists
if (!response.data || typeof response.data !== 'string') {
throw new Error(`Invalid response from CalDAV server: expected string data, got ${typeof response.data}`);
}
// Parse iCalendar data from response
const events = parseICalendarEvents(response.data);
@ -399,7 +418,8 @@ export async function syncInfomaniakCalendar(
});
// Create a map of existing events by externalEventId (UID) for fast lookup
const existingEventsByExternalId = new Map<string, typeof existingEvents[0]>();
type EventType = typeof existingEvents[number];
const existingEventsByExternalId = new Map<string, EventType>();
for (const event of existingEvents) {
if (event.externalEventId) {
existingEventsByExternalId.set(event.externalEventId, event);
@ -413,14 +433,14 @@ export async function syncInfomaniakCalendar(
// Sync events: create or update
for (const caldavEvent of caldavEvents) {
// Priority 1: Match by externalEventId (UID) - most reliable
let existingEvent = caldavEvent.uid
let existingEvent: EventType | undefined = caldavEvent.uid
? existingEventsByExternalId.get(caldavEvent.uid)
: undefined;
// Priority 2: Fallback to title + date matching for events without externalEventId (backward compatibility)
if (!existingEvent) {
existingEvent = existingEvents.find(
(e) => {
(e: EventType) => {
if (!e.externalEventId && // Only match events that don't have externalEventId yet
e.title === caldavEvent.summary) {
const timeDiff = Math.abs(new Date(e.start).getTime() - caldavEvent.start.getTime());
@ -431,29 +451,33 @@ export async function syncInfomaniakCalendar(
);
}
const eventData = {
// For updates, we cannot modify calendarId and userId (they are relations)
// For creates, we need them
const baseEventData = {
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,
externalEventId: caldavEvent.uid, // Store UID for reliable matching
};
if (existingEvent) {
// Update existing event
// Update existing event (without calendarId and userId - they are relations)
await prisma.event.update({
where: { id: existingEvent.id },
data: eventData,
data: baseEventData,
});
updated++;
} else {
// Create new event
// Create new event (with calendarId and userId)
await prisma.event.create({
data: eventData,
data: {
...baseEventData,
calendarId: syncConfig.calendarId,
userId: syncConfig.calendar.userId,
},
});
created++;
}

View File

@ -497,23 +497,23 @@ export async function syncMicrosoftCalendar(
// Clean description (remove [MS_ID:xxx] prefix if present from previous syncs)
const cleanedDescription = cleanDescription(caldavEvent.description);
const eventData = {
// For updates, we cannot modify calendarId and userId (they are relations)
// For creates, we need them
const baseEventData = {
title: caldavEvent.summary,
description: cleanedDescription, // Clean description without [MS_ID:xxx] prefix
start: caldavEvent.start,
end: caldavEvent.end,
location: caldavEvent.location || null,
isAllDay: caldavEvent.allDay,
calendarId: syncConfig.calendarId,
userId: syncConfig.calendar.userId,
externalEventId: microsoftId, // Store Microsoft ID for reliable matching
};
if (existingEvent) {
// Update existing event
// Update existing event (without calendarId and userId - they are relations)
await prisma.event.update({
where: { id: existingEvent.id },
data: eventData,
data: baseEventData,
});
updated++;
logger.debug('Updated event', {
@ -522,9 +522,13 @@ export async function syncMicrosoftCalendar(
microsoftId,
});
} else {
// Create new event
// Create new event (with calendarId and userId)
const newEvent = await prisma.event.create({
data: eventData,
data: {
...baseEventData,
calendarId: syncConfig.calendarId,
userId: syncConfig.calendar.userId,
},
});
created++;
logger.debug('Created new event', {