fix: handle updates and deletions for synthetic JMAP IDs in calendar events
This commit is contained in:
@@ -470,9 +470,22 @@ export default function CalendarPage() {
|
||||
try {
|
||||
if (type === "edit" && updates) {
|
||||
switch (scope) {
|
||||
case "this":
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
case "this": {
|
||||
// Synthetic IDs (from expandRecurrences) can't be updated directly.
|
||||
// Patch the master event's recurrenceOverrides instead.
|
||||
const master = await findMasterEvent(event);
|
||||
if (master && event.recurrenceId) {
|
||||
const patchUpdates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||
patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value;
|
||||
}
|
||||
await updateEvent(client, master.id, patchUpdates as Partial<CalendarEvent>, sendScheduling);
|
||||
} else {
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "this_and_future": {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (!result) {
|
||||
@@ -530,9 +543,20 @@ export default function CalendarPage() {
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
switch (scope) {
|
||||
case "this":
|
||||
await deleteEvent(client, event.id, sendScheduling);
|
||||
case "this": {
|
||||
// Synthetic IDs (from expandRecurrences) can't be destroyed directly.
|
||||
// Exclude the instance via recurrenceOverrides on the master event.
|
||||
const delMaster = await findMasterEvent(event);
|
||||
if (delMaster && event.recurrenceId) {
|
||||
await updateEvent(
|
||||
client, delMaster.id,
|
||||
{ [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial<CalendarEvent>,
|
||||
);
|
||||
} else {
|
||||
await deleteEvent(client, event.id, sendScheduling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "this_and_future": {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (!result) {
|
||||
|
||||
+106
-38
@@ -172,24 +172,40 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
try {
|
||||
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
} catch (updateError) {
|
||||
// Stalwart doesn't support updating events created via CalDAV (e.g. by Thunderbird).
|
||||
// These events have "synthetic" JMAP IDs. Work around by destroying and recreating.
|
||||
// Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
|
||||
// or expanded recurring-event instances returned by expandRecurrences).
|
||||
// Resolve the real event via a UID query and retry.
|
||||
const message = updateError instanceof Error ? updateError.message : '';
|
||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||
debug.log('Event has synthetic ID, falling back to destroy+recreate');
|
||||
// Merge existing event with updates, strip server-computed fields
|
||||
const { id: _id, originalId: _oi, originalCalendarIds: _oc,
|
||||
accountId: _ai, accountName: _an, isShared: _is,
|
||||
utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io,
|
||||
created: _cr, updated: _up, ...baseEvent } = storeEvent;
|
||||
const mergedEvent: Partial<CalendarEvent> = { ...baseEvent, ...cleanUpdates };
|
||||
// Delete the old event without sending cancellations (we're recreating it)
|
||||
await client.deleteCalendarEvent(realId, false, targetAccountId);
|
||||
const created = await client.createCalendarEvent(mergedEvent, sendSchedulingMessages, targetAccountId);
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? created : e),
|
||||
}));
|
||||
return;
|
||||
debug.log('Event has synthetic ID, resolving real ID via UID query');
|
||||
const queryResults = await client.queryCalendarEvents(
|
||||
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
||||
);
|
||||
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
||||
if (realEvent) {
|
||||
const resolvedId = realEvent.originalId || realEvent.id;
|
||||
if (storeEvent.recurrenceId) {
|
||||
// Recurring instance: patch the master event's recurrenceOverrides
|
||||
const patchUpdates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
|
||||
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||
patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value;
|
||||
}
|
||||
await client.updateCalendarEvent(
|
||||
resolvedId,
|
||||
patchUpdates as unknown as Partial<CalendarEvent>,
|
||||
sendSchedulingMessages,
|
||||
targetAccountId
|
||||
);
|
||||
} else {
|
||||
// Non-recurring event with synthetic ID: retry with the real ID
|
||||
await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
@@ -233,29 +249,49 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
targetAccountId
|
||||
);
|
||||
} catch (updateError) {
|
||||
// Fallback for CalDAV-created events with synthetic IDs
|
||||
// Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query.
|
||||
const message = updateError instanceof Error ? updateError.message : '';
|
||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||
debug.log('RSVP: Event has synthetic ID, falling back to destroy+recreate');
|
||||
const { id: _id, originalId: _oi, originalCalendarIds: _oc,
|
||||
accountId: _ai, accountName: _an, isShared: _is,
|
||||
utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io,
|
||||
created: _cr, updated: _up, ...baseEvent } = storeEvent;
|
||||
const updatedParticipants = storeEvent.participants ? {
|
||||
...storeEvent.participants,
|
||||
[participantId]: { ...storeEvent.participants[participantId], participationStatus: status },
|
||||
} : storeEvent.participants;
|
||||
const mergedEvent: Partial<CalendarEvent> = {
|
||||
...baseEvent,
|
||||
participants: updatedParticipants as Record<string, CalendarParticipant> | null,
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
};
|
||||
await client.deleteCalendarEvent(realId, false, targetAccountId);
|
||||
const created = await client.createCalendarEvent(mergedEvent, true, targetAccountId);
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === eventId ? created : e),
|
||||
}));
|
||||
return;
|
||||
debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
|
||||
const queryResults = await client.queryCalendarEvents(
|
||||
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
||||
);
|
||||
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
||||
if (realEvent) {
|
||||
const resolvedId = realEvent.originalId || realEvent.id;
|
||||
if (storeEvent.recurrenceId) {
|
||||
// Recurring instance: patch RSVP as recurrence override on master
|
||||
const overridePatch: Record<string, unknown> = {
|
||||
[`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status,
|
||||
};
|
||||
if (replyTo) {
|
||||
overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo;
|
||||
}
|
||||
await client.updateCalendarEvent(
|
||||
resolvedId,
|
||||
overridePatch as unknown as Partial<CalendarEvent>,
|
||||
true,
|
||||
targetAccountId
|
||||
);
|
||||
} else {
|
||||
// Non-recurring event: retry RSVP with real ID
|
||||
await client.updateCalendarEvent(
|
||||
resolvedId,
|
||||
patch as unknown as Partial<CalendarEvent>,
|
||||
true,
|
||||
targetAccountId
|
||||
);
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === eventId ? { ...e, participants: {
|
||||
...e.participants,
|
||||
...(e.participants?.[participantId] ? {
|
||||
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
|
||||
} : {}),
|
||||
}} : e),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
@@ -402,7 +438,39 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
debug.error('Failed to send cancellation emails:', e);
|
||||
}
|
||||
}
|
||||
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||
try {
|
||||
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||
} catch (deleteError) {
|
||||
// Stalwart rejects deletes on synthetic IDs (CalDAV-created events or
|
||||
// expanded recurring instances). Resolve the real ID via UID query.
|
||||
const message = deleteError instanceof Error ? deleteError.message : '';
|
||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||
debug.log('Event has synthetic ID, resolving real ID via UID query for delete');
|
||||
const queryResults = await client.queryCalendarEvents(
|
||||
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
||||
);
|
||||
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
||||
if (realEvent) {
|
||||
const resolvedId = realEvent.originalId || realEvent.id;
|
||||
if (storeEvent.recurrenceId) {
|
||||
// Recurring instance: exclude via recurrenceOverrides on master
|
||||
await client.updateCalendarEvent(
|
||||
resolvedId,
|
||||
{ [`recurrenceOverrides/${storeEvent.recurrenceId}`]: { excluded: true } } as unknown as Partial<CalendarEvent>,
|
||||
false,
|
||||
targetAccountId
|
||||
);
|
||||
} else {
|
||||
// Non-recurring event: delete using the real ID
|
||||
await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
} else {
|
||||
throw deleteError;
|
||||
}
|
||||
} else {
|
||||
throw deleteError;
|
||||
}
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => e.id !== id),
|
||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||
|
||||
Reference in New Issue
Block a user