mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix(calendar): edit a single recurring occurrence via a one-shot override patch
The "This event only" edit path built nested JMAP patch pointers (recurrenceOverrides/<recurrenceId>/<field>). On the first edit of an occurrence the override object doesn't exist yet, and JMAP pointers can't create a missing intermediate object, so Stalwart returned notUpdated and the edit failed with two error toasts. Build the override as a single pointer recurrenceOverrides/<recurrenceId> whose value is the occurrence's field set (dropping identity/series keys), mirroring the working "delete this occurrence" path. Extracted as the pure, tested helper buildRecurrenceOverridePatch. Closes #774.
This commit is contained in:
@@ -51,6 +51,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { buildRecurrenceOverridePatch } from "@/lib/recurrence-overrides";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -826,12 +827,8 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
|
||||
// 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);
|
||||
const patchUpdates = buildRecurrenceOverridePatch(updates, event.recurrenceId);
|
||||
await updateEvent(client, master.id, patchUpdates, sendScheduling);
|
||||
} else {
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRecurrenceOverridePatch } from '@/lib/recurrence-overrides';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
const RID = '2026-08-15T10:00:00';
|
||||
|
||||
describe('buildRecurrenceOverridePatch (#774 "This event only")', () => {
|
||||
it('sets the whole override at a single pointer, not nested per-field pointers', () => {
|
||||
const patch = buildRecurrenceOverridePatch(
|
||||
{ title: 'Moved standup', start: '2026-08-15T11:00:00' } as Partial<CalendarEvent>,
|
||||
RID,
|
||||
);
|
||||
// Exactly one top-level pointer, at the override object itself.
|
||||
expect(Object.keys(patch)).toEqual([`recurrenceOverrides/${RID}`]);
|
||||
// No nested `recurrenceOverrides/<id>/<field>` pointers (the shape Stalwart
|
||||
// rejected because it can't create the missing override object).
|
||||
expect(Object.keys(patch).some((k) => k.startsWith(`recurrenceOverrides/${RID}/`))).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the per-occurrence fields into the override object', () => {
|
||||
const patch = buildRecurrenceOverridePatch(
|
||||
{
|
||||
title: 'Moved standup',
|
||||
start: '2026-08-15T11:00:00',
|
||||
duration: 'PT30M',
|
||||
description: 'one-off',
|
||||
} as Partial<CalendarEvent>,
|
||||
RID,
|
||||
);
|
||||
expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({
|
||||
title: 'Moved standup',
|
||||
start: '2026-08-15T11:00:00',
|
||||
duration: 'PT30M',
|
||||
description: 'one-off',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops identity and series-level keys that must not vary per occurrence', () => {
|
||||
const patch = buildRecurrenceOverridePatch(
|
||||
{
|
||||
id: 'evt1',
|
||||
uid: 'uid-1',
|
||||
'@type': 'Event',
|
||||
calendarIds: { cal1: true },
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
|
||||
recurrenceOverrides: {},
|
||||
excludedRecurrenceRules: [],
|
||||
title: 'Only this',
|
||||
} as unknown as Partial<CalendarEvent>,
|
||||
RID,
|
||||
);
|
||||
expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({ title: 'Only this' });
|
||||
});
|
||||
|
||||
it('produces an empty override object when nothing overridable is supplied', () => {
|
||||
const patch = buildRecurrenceOverridePatch({ id: 'evt1', uid: 'uid-1' } as unknown as Partial<CalendarEvent>, RID);
|
||||
expect(patch[`recurrenceOverrides/${RID}` as keyof typeof patch]).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
/** Identity / whole-series keys that must never go into a single-occurrence override. */
|
||||
export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [
|
||||
'id',
|
||||
'uid',
|
||||
'@type',
|
||||
'calendarIds',
|
||||
'recurrenceRules',
|
||||
'recurrenceOverrides',
|
||||
'excludedRecurrenceRules',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Build the "This event only" override patch for a recurring master.
|
||||
*
|
||||
* Why one pointer: a JMAP nested pointer can't create a missing intermediate,
|
||||
* so the override object has to be set whole at `recurrenceOverrides/<id>`, not
|
||||
* field-by-field (#774).
|
||||
*/
|
||||
export function buildRecurrenceOverridePatch(
|
||||
updates: Partial<CalendarEvent>,
|
||||
recurrenceId: string,
|
||||
): Partial<CalendarEvent> {
|
||||
const immutable = new Set<string>(RECURRENCE_OVERRIDE_IMMUTABLE_KEYS);
|
||||
const override: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (immutable.has(key)) continue;
|
||||
override[key] = value;
|
||||
}
|
||||
return { [`recurrenceOverrides/${recurrenceId}`]: override } as Partial<CalendarEvent>;
|
||||
}
|
||||
Reference in New Issue
Block a user