mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: edit or answer one occurrence of a series in the app on Stalwart 1.0
Stalwart 1.0 refuses series properties such as the organizer on a single occurrence unless they repeat the series' value, and refuses them inside a recurrence override on the base event. The event editor wrote the user's first address as organizerCalendarAddress on every save of an event with attendees, so editing one occurrence failed with "This property cannot be modified on a single occurrence." whenever the series was organized by someone else, under another alias or in another letter case. Removing every attendee from one occurrence failed the same way. - leave series keys and per-event metadata out of changes to one occurrence, as Stalwart's validate.rs lists them, and pointers at a participant's calendarAddress - write overrides on the base event as before (0.16.19 needs the organizer there) and, when the server refuses series keys inside them, repeat the write once without them and remember that - answer one occurrence through its synthetic id without the organizer - let the editor keep the organizer of an event: an invitation the user received is no longer rewritten with the user as organizer, and organizerCalendarAddress is only written for an event that has none - keep the SetError type on a refused event update 0.16.20 to 0.16.23 dropped these keys on synthetic ids anyway; the base-event override payload for older servers is unchanged.
This commit is contained in:
@@ -476,6 +476,25 @@ describe('calendar operations', () => {
|
||||
ev1: { recurrenceRule: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the SetError type and description of a refused update (Stalwart 1.0 override rule)', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', {
|
||||
notUpdated: {
|
||||
ev1: {
|
||||
type: 'invalidProperties',
|
||||
description: 'organizerCalendarAddress cannot be set inside recurrenceOverrides.',
|
||||
properties: ['recurrenceOverrides'],
|
||||
},
|
||||
},
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
await expect(updateEvent('ev1', { title: 'x' })).rejects.toMatchObject({
|
||||
type: 'invalidProperties',
|
||||
message: 'organizerCalendarAddress cannot be set inside recurrenceOverrides.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEventsByUid', () => {
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Calendar, CalendarEvent, CalendarRights } from './types';
|
||||
import { assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
|
||||
import { JMAPMethodError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
|
||||
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
|
||||
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
||||
import {
|
||||
@@ -910,7 +910,9 @@ export async function updateEvent(
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update event');
|
||||
// Keep the SetError type: the store retries some refusals differently
|
||||
// (isOverrideSeriesKeyRejection).
|
||||
if (err) throw new JMAPMethodError(err.type ?? 'setError', err.description || err.type || 'Failed to update event');
|
||||
}
|
||||
|
||||
export async function deleteEvents(
|
||||
|
||||
@@ -58,8 +58,7 @@ import {
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
||||
import {
|
||||
buildParticipantMap,
|
||||
getParticipantList,
|
||||
participantFieldsToSave,
|
||||
seedAttendees,
|
||||
type Attendee,
|
||||
} from '../../lib/calendar-participants';
|
||||
@@ -327,28 +326,12 @@ export function EventModal({
|
||||
locations: clearedOr(buildLocations(location), 'locations'),
|
||||
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
|
||||
};
|
||||
const organizerEmail = currentUserEmails[0];
|
||||
if (attendees.length > 0 && organizerEmail) {
|
||||
// A new event has no participants yet, so it would store an empty
|
||||
// organizer name; fall back to the contact card / account name.
|
||||
const organizerName =
|
||||
(event ? getParticipantList(event).find((p) => p.isOrganizer)?.name : '')
|
||||
|| resolveContactName(organizerEmail)
|
||||
|| '';
|
||||
data.participants = buildParticipantMap(
|
||||
{ name: organizerName, email: organizerEmail },
|
||||
attendees,
|
||||
);
|
||||
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||
// scheduling is silently skipped, so no invites are sent.
|
||||
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||
} else if (attendees.length === 0 && isEdit && had('participants')) {
|
||||
data.participants = null;
|
||||
// Also clear the retired replyTo that older releases wrote.
|
||||
if (event?.replyTo) data.replyTo = null;
|
||||
if (event?.organizerCalendarAddress) data.organizerCalendarAddress = null;
|
||||
}
|
||||
// An invitation the user received keeps its organizer; only an event
|
||||
// without one is organized by the user.
|
||||
Object.assign(data, participantFieldsToSave(event, attendees, {
|
||||
userEmail: currentUserEmails[0],
|
||||
resolveName: resolveContactName,
|
||||
}));
|
||||
if (data.recurrenceRules === null) {
|
||||
// Dropping the rule leaves per-occurrence data dangling.
|
||||
if (had('recurrenceOverrides')) data.recurrenceOverrides = null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getStatusCounts,
|
||||
getUserParticipantId,
|
||||
isOrganizer,
|
||||
participantFieldsToSave,
|
||||
seedAttendees,
|
||||
} from '../calendar-participants';
|
||||
|
||||
@@ -171,3 +172,84 @@ describe('getParticipantList dedupe, names and organizer status', () => {
|
||||
})).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// The editor rebuilds the participants on every save. It used to make the
|
||||
// user the organizer of every event with attendees, invitations they only
|
||||
// received included, and to write their first address as
|
||||
// organizerCalendarAddress - which Stalwart 1.0 also refuses on one
|
||||
// occurrence of a series organized under another address or letter case.
|
||||
describe('participantFieldsToSave', () => {
|
||||
const attendees = [{ name: 'Bob', email: 'bob@example.com' }];
|
||||
const owner = (fields: ReturnType<typeof participantFieldsToSave>) =>
|
||||
Object.values(fields.participants ?? {}).find((p) => p.roles?.owner);
|
||||
|
||||
it('makes the user the organizer of a new event and writes the address', () => {
|
||||
const fields = participantFieldsToSave(null, attendees, {
|
||||
userEmail: 'me@example.com',
|
||||
resolveName: (email) => (email === 'me@example.com' ? 'Me' : undefined),
|
||||
});
|
||||
|
||||
expect(owner(fields)).toMatchObject({ email: 'me@example.com', name: 'Me' });
|
||||
expect(fields.organizerCalendarAddress).toBe('mailto:me@example.com');
|
||||
});
|
||||
|
||||
it('keeps the organizer of an invitation the user received', () => {
|
||||
const invitation: Partial<CalendarEvent> = {
|
||||
organizerCalendarAddress: 'mailto:boss@example.com',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.com', name: 'Boss' },
|
||||
me: { calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
},
|
||||
};
|
||||
const rows = seedAttendees(invitation, ['me@example.com']);
|
||||
|
||||
const fields = participantFieldsToSave(invitation, rows, { userEmail: 'me@example.com' });
|
||||
|
||||
expect(owner(fields)).toMatchObject({ email: 'boss@example.com', name: 'Boss', calendarAddress: 'mailto:boss@example.com' });
|
||||
expect(Object.values(fields.participants ?? {}).map((p) => p.email).sort()).toEqual(['boss@example.com', 'me@example.com']);
|
||||
expect(fields).not.toHaveProperty('organizerCalendarAddress');
|
||||
});
|
||||
|
||||
it('keeps the exact address a series is organized under', () => {
|
||||
const series: Partial<CalendarEvent> = {
|
||||
organizerCalendarAddress: 'mailto:Team@Example.com',
|
||||
participants: { org: { calendarAddress: 'mailto:Team@Example.com' }, bob: { email: 'bob@example.com' } },
|
||||
};
|
||||
|
||||
const fields = participantFieldsToSave(series, attendees, { userEmail: 'me@example.com' });
|
||||
|
||||
expect(owner(fields)?.email).toBe('Team@Example.com');
|
||||
expect(fields).not.toHaveProperty('organizerCalendarAddress');
|
||||
});
|
||||
|
||||
it('takes the organizer from organizerCalendarAddress when no participant names it', () => {
|
||||
const fields = participantFieldsToSave(
|
||||
{ organizerCalendarAddress: 'mailto:boss@example.com', participants: {} },
|
||||
attendees,
|
||||
{ userEmail: 'me@example.com' },
|
||||
);
|
||||
|
||||
expect(owner(fields)?.email).toBe('boss@example.com');
|
||||
expect(fields).not.toHaveProperty('organizerCalendarAddress');
|
||||
});
|
||||
|
||||
it('makes the user the organizer of an event that had none and writes the address', () => {
|
||||
const fields = participantFieldsToSave({ title: 'Lunch' }, attendees, { userEmail: 'me@example.com' });
|
||||
|
||||
expect(owner(fields)?.email).toBe('me@example.com');
|
||||
expect(fields.organizerCalendarAddress).toBe('mailto:me@example.com');
|
||||
});
|
||||
|
||||
it('clears the participants and the organizer when every attendee is removed', () => {
|
||||
expect(participantFieldsToSave(event, [], { userEmail: 'alice@example.com' })).toEqual({
|
||||
participants: null,
|
||||
organizerCalendarAddress: null,
|
||||
});
|
||||
expect(participantFieldsToSave(
|
||||
{ participants: event.participants, replyTo: { imip: 'mailto:alice@example.com' } },
|
||||
[],
|
||||
{ userEmail: 'alice@example.com' },
|
||||
)).toEqual({ participants: null, replyTo: null });
|
||||
expect(participantFieldsToSave({ title: 'Lunch' }, [], { userEmail: 'me@example.com' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildOccurrenceRsvpPatch,
|
||||
hydrateRecurrenceInstances,
|
||||
isBrowserExpandedOccurrence,
|
||||
isOverrideSeriesKeyRejection,
|
||||
isServerRecurrenceInstance,
|
||||
isSyntheticIdMutationUnsupported,
|
||||
mergeOverrideParticipants,
|
||||
@@ -14,8 +15,10 @@ import {
|
||||
seriesIdOf,
|
||||
stableOccurrenceKey,
|
||||
withNewOverrideDetails,
|
||||
withoutOverrideSeriesKeys,
|
||||
} from '../recurrence-instances';
|
||||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||
import { JMAPMethodError } from '../../api/jmap-result';
|
||||
import type { CalendarEvent } from '../../api/types';
|
||||
|
||||
function ev(partial: Partial<CalendarEvent>): CalendarEvent {
|
||||
@@ -231,7 +234,7 @@ describe('changing one occurrence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('answers one occurrence with the whole participant map and the organizer', () => {
|
||||
it('answers one occurrence with the whole participant map', () => {
|
||||
const occurrence = ev({
|
||||
id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
|
||||
organizerCalendarAddress: 'mailto:org@x', sequence: 1,
|
||||
@@ -241,15 +244,130 @@ describe('changing one occurrence', () => {
|
||||
me: { calendarAddress: 'mailto:me@x', participationStatus: 'needs-action' },
|
||||
} as any,
|
||||
});
|
||||
const participants = {
|
||||
org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:me@x', participationStatus: 'accepted' },
|
||||
};
|
||||
|
||||
expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted')).toEqual({
|
||||
// Through a synthetic id: the organizer is the series', which Stalwart
|
||||
// 1.0 checks against the base event.
|
||||
expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted')).toEqual({ sequence: 1, participants });
|
||||
// In an override on the base event: 0.16.19 needs the organizer there.
|
||||
expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted', { seriesKeys: true })).toEqual({
|
||||
sequence: 1,
|
||||
organizerCalendarAddress: 'mailto:org@x',
|
||||
participants: {
|
||||
org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:me@x', participationStatus: 'accepted' },
|
||||
},
|
||||
participants,
|
||||
});
|
||||
expect(buildOccurrenceRsvpPatch(occurrence, 'nobody', 'accepted')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Stalwart 1.0 checks what one occurrence carries (jmap/src/calendar_event/
|
||||
// validate.rs): through a synthetic id a series property must repeat the base
|
||||
// event's value, inside an override on the base event it is refused whatever
|
||||
// its value. 0.16.20-0.16.23 drop such keys silently; older 0.16 store them.
|
||||
describe('series keys on one occurrence', () => {
|
||||
it('leaves the series keys and pointers at an address out of an occurrence patch', () => {
|
||||
const patch = buildOccurrencePatch({
|
||||
privacy: 'public',
|
||||
organizerCalendarAddress: 'mailto:me@example.com',
|
||||
method: 'request',
|
||||
prodId: '-//Bulwark//EN',
|
||||
sentBy: 'mailto:assistant@example.com',
|
||||
recurrenceRule: { frequency: 'daily' },
|
||||
excludedRecurrenceRule: null,
|
||||
'participants/p1/calendarAddress': 'mailto:other@example.com',
|
||||
'participants/p1/participationStatus': 'declined',
|
||||
participants: { p1: { '@type': 'Participant', calendarAddress: 'mailto:p1@example.com' } },
|
||||
relatedTo: { r1: { '@type': 'Relation' } },
|
||||
sequence: 3,
|
||||
excluded: false,
|
||||
status: 'tentative',
|
||||
freeBusyStatus: 'free',
|
||||
accountId: 'acc-2',
|
||||
} as unknown as Partial<CalendarEvent>);
|
||||
|
||||
expect(patch).toEqual({
|
||||
'participants/p1/participationStatus': 'declined',
|
||||
participants: { p1: { '@type': 'Participant', calendarAddress: 'mailto:p1@example.com' } },
|
||||
relatedTo: { r1: { '@type': 'Relation' } },
|
||||
sequence: 3,
|
||||
excluded: false,
|
||||
status: 'tentative',
|
||||
freeBusyStatus: 'free',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the organizer in an override on the base event, as 0.16 has always been sent it', () => {
|
||||
const occurrence = ev({
|
||||
id: 'm:2026-03-03T09:00:00', originalId: 'm', recurrenceId: '2026-03-03T09:00:00',
|
||||
start: '2026-03-03T09:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any],
|
||||
recurrenceOverrides: null,
|
||||
});
|
||||
|
||||
expect(buildFallbackOverridePatch(occurrence, {
|
||||
title: 'x', organizerCalendarAddress: 'mailto:org@example.com', uid: 'u', calendarIds: { c: true },
|
||||
})).toEqual({
|
||||
recurrenceOverrides: {
|
||||
'2026-03-03T09:00:00': {
|
||||
start: '2026-03-03T09:00:00', duration: 'PT1H', title: 'x', organizerCalendarAddress: 'mailto:org@example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('copies the organizer into a new override only when it goes on the base event', () => {
|
||||
const occurrence = ev({
|
||||
id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
|
||||
organizerCalendarAddress: 'mailto:org@example.com', recurrenceOverrides: null,
|
||||
});
|
||||
|
||||
expect(withNewOverrideDetails(occurrence, { start: 'x' })).not.toHaveProperty('organizerCalendarAddress');
|
||||
expect(withNewOverrideDetails(occurrence, { start: 'x' }, { seriesKeys: true })).toMatchObject({
|
||||
organizerCalendarAddress: 'mailto:org@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the series and per-event keys out of every override an update writes', () => {
|
||||
expect(withoutOverrideSeriesKeys({
|
||||
title: 'Series title',
|
||||
organizerCalendarAddress: 'mailto:org@example.com',
|
||||
recurrenceOverrides: {
|
||||
'2026-09-08T10:00:00': { title: 'Moved', privacy: 'public', organizerCalendarAddress: 'mailto:o@example.com' },
|
||||
'2026-09-09T10:00:00': { excluded: true },
|
||||
},
|
||||
})).toEqual({
|
||||
title: 'Series title',
|
||||
organizerCalendarAddress: 'mailto:org@example.com',
|
||||
recurrenceOverrides: {
|
||||
'2026-09-08T10:00:00': { title: 'Moved' },
|
||||
'2026-09-09T10:00:00': { excluded: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('takes them out of an override entry and of pointers into one', () => {
|
||||
expect(withoutOverrideSeriesKeys({
|
||||
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00', privacy: 'public', uid: 'x' },
|
||||
'recurrenceOverrides/2026-09-09T10:00:00/privacy': 'public',
|
||||
'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/calendarAddress': 'mailto:x@example.com',
|
||||
'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/participationStatus': 'accepted',
|
||||
'recurrenceOverrides/2026-09-10T10:00:00': null,
|
||||
})).toEqual({
|
||||
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00' },
|
||||
'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/participationStatus': 'accepted',
|
||||
'recurrenceOverrides/2026-09-10T10:00:00': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises Stalwart 1.0 refusing a series key inside an override', () => {
|
||||
expect(isOverrideSeriesKeyRejection(
|
||||
new JMAPMethodError('invalidProperties', 'organizerCalendarAddress cannot be set inside recurrenceOverrides.'),
|
||||
)).toBe(true);
|
||||
expect(isOverrideSeriesKeyRejection(
|
||||
new JMAPMethodError('invalidProperties', 'This property cannot be modified on a single occurrence.'),
|
||||
)).toBe(false);
|
||||
// Without the SetError type it is not recognised.
|
||||
expect(isOverrideSeriesKeyRejection(new Error('privacy cannot be set inside recurrenceOverrides.'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,6 +296,68 @@ export function buildParticipantMap(
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's own organizer: the organizer participant, else the address in
|
||||
* `organizerCalendarAddress` or the retired `replyTo`. `null` for an event
|
||||
* without one.
|
||||
*/
|
||||
function eventOrganizer(event: Partial<CalendarEvent>): { name: string; email: string } | null {
|
||||
const participant = getParticipantList(event).find((p) => p.isOrganizer && p.email.trim());
|
||||
if (participant) return { name: participant.name, email: participant.email.trim() };
|
||||
const address = event.organizerCalendarAddress || Object.values(event.replyTo ?? {})[0];
|
||||
const email = address?.replace(/^mailto:/i, '').trim();
|
||||
return email ? { name: '', email } : null;
|
||||
}
|
||||
|
||||
export interface ParticipantSaveContext {
|
||||
/** The user's first calendar address: the organizer of invitations sent from the editor. */
|
||||
userEmail: string | undefined;
|
||||
/** A name for an organizer the event data carries none for (the contact card, the account). */
|
||||
resolveName?: (email: string) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The participant fields an editor save writes.
|
||||
*
|
||||
* With attendees, the participants are rebuilt around the event's own
|
||||
* organizer: an invitation keeps whoever sent it, and a series the exact
|
||||
* address it is organized under (Stalwart 1.0 checks it on single
|
||||
* occurrences). Only an event without an organizer gets the user, and only
|
||||
* then `organizerCalendarAddress` is written: Stalwart derives the iCalendar
|
||||
* ORGANIZER solely from it, so without one no invitations go out.
|
||||
*
|
||||
* Without attendees, an event that had participants loses them, its
|
||||
* organizer and the retired `replyTo` older releases wrote.
|
||||
*/
|
||||
export function participantFieldsToSave(
|
||||
event: Partial<CalendarEvent> | null | undefined,
|
||||
attendees: Attendee[],
|
||||
context: ParticipantSaveContext,
|
||||
): Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>> {
|
||||
const { userEmail, resolveName } = context;
|
||||
if (attendees.length > 0 && userEmail) {
|
||||
const organizer = (event ? eventOrganizer(event) : null) ?? { name: '', email: userEmail };
|
||||
const fields: Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress'>> = {
|
||||
participants: buildParticipantMap(
|
||||
// A new event has no participants yet, so it would store an empty
|
||||
// organizer name.
|
||||
{ name: organizer.name || resolveName?.(organizer.email) || '', email: organizer.email },
|
||||
attendees,
|
||||
),
|
||||
};
|
||||
if (!event?.organizerCalendarAddress) fields.organizerCalendarAddress = `mailto:${organizer.email}`;
|
||||
return fields;
|
||||
}
|
||||
if (attendees.length === 0 && event?.participants && Object.keys(event.participants).length > 0) {
|
||||
return {
|
||||
participants: null,
|
||||
...(event.replyTo ? { replyTo: null } : {}),
|
||||
...(event.organizerCalendarAddress ? { organizerCalendarAddress: null } : {}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The attendee rows an editor should start from: every participant except the
|
||||
* organizer (who is re-added by buildParticipantMap on save), without
|
||||
|
||||
+130
-26
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
import type { CalendarEvent, Participant } from '../api/types';
|
||||
import { jmapErrorType } from '../api/jmap-result';
|
||||
import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from './recurrence-overrides';
|
||||
|
||||
/**
|
||||
@@ -282,6 +283,35 @@ export const OCCURRENCE_REJECTED_KEYS = [
|
||||
'hideAttendees',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Properties of the whole series, plus the app's plural rule names. Stalwart
|
||||
* 1.0 refuses them inside a recurrence override, and on a synthetic id
|
||||
* unless they equal the base event's value; 0.16.20 to 0.16.23 drop them
|
||||
* there (jmap/src/calendar_event/validate.rs, is_series_property).
|
||||
*/
|
||||
export const SERIES_KEYS = [
|
||||
'@type',
|
||||
'method',
|
||||
'organizerCalendarAddress',
|
||||
'privacy',
|
||||
'prodId',
|
||||
'recurrenceId',
|
||||
'recurrenceIdTimeZone',
|
||||
'sentBy',
|
||||
'uid',
|
||||
'recurrenceOverrides',
|
||||
'recurrenceRule',
|
||||
'recurrenceRules',
|
||||
'excludedRecurrenceRule',
|
||||
'excludedRecurrenceRules',
|
||||
] as const;
|
||||
|
||||
/** Per-event metadata (validate.rs, is_event_metadata): never part of one occurrence. */
|
||||
export const EVENT_METADATA_KEYS = ['id', ...OCCURRENCE_REJECTED_KEYS] as const;
|
||||
|
||||
// Fields the app keeps on its events that no server knows.
|
||||
const CLIENT_ONLY_KEYS = ['originalId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId'] as const;
|
||||
|
||||
/**
|
||||
* Whether a `CalendarEvent/set` failure is a pre-0.16.20 server refusing a
|
||||
* synthetic id ("Updating/Deleting synthetic ids is not yet supported.").
|
||||
@@ -293,39 +323,95 @@ export function isSyntheticIdMutationUnsupported(error: unknown): boolean {
|
||||
return /synthetic ids?\b.*\bnot (?:yet )?supported/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `CalendarEvent/set` failure is Stalwart 1.0 refusing a series
|
||||
* key inside a recurrence override (invalidProperties, "privacy cannot be
|
||||
* set inside recurrenceOverrides.").
|
||||
*/
|
||||
export function isOverrideSeriesKeyRejection(error: unknown): boolean {
|
||||
return jmapErrorType(error) === 'invalidProperties'
|
||||
&& /cannot be set inside recurrenceOverrides/i.test(error instanceof Error ? error.message : '');
|
||||
}
|
||||
|
||||
function firstPointerSegment(key: string): string {
|
||||
const slash = key.indexOf('/');
|
||||
return slash === -1 ? key : key.slice(0, slash);
|
||||
}
|
||||
|
||||
const NOT_PER_OCCURRENCE = new Set<string>([...SERIES_KEYS, ...EVENT_METADATA_KEYS, ...CLIENT_ONLY_KEYS]);
|
||||
|
||||
// A participant's address is the series': 1.0 refuses a pointer at it inside
|
||||
// an override, and on a synthetic id unless it is unchanged.
|
||||
const PARTICIPANT_ADDRESS_POINTER = /^participants\/[^/]+\/calendarAddress$/;
|
||||
|
||||
function isPerOccurrenceKey(key: string): boolean {
|
||||
return !NOT_PER_OCCURRENCE.has(firstPointerSegment(key)) && !PARTICIPANT_ADDRESS_POINTER.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an event patch to what may be written to a single occurrence
|
||||
* through its synthetic id: identity / whole-series keys and the per-event
|
||||
* keys the server rejects are dropped (also when addressed through a JSON
|
||||
* pointer such as `calendarIds/x`). Everything else - including pointer
|
||||
* patches like `locations/loc1/name` - passes through unchanged.
|
||||
* Reduce an event patch to what one occurrence may carry, through its
|
||||
* synthetic id or inside its override: series keys and per-event metadata
|
||||
* are dropped (also when addressed through a JSON pointer such as
|
||||
* `calendarIds/x`), and so is a pointer at a participant's calendarAddress.
|
||||
* Everything else - including pointer patches like `locations/loc1/name` and
|
||||
* a whole `participants` map - passes through unchanged.
|
||||
*/
|
||||
export function buildOccurrencePatch(updates: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
const dropped = new Set<string>([
|
||||
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
|
||||
...OCCURRENCE_REJECTED_KEYS,
|
||||
'recurrenceId',
|
||||
'recurrenceIdTimeZone',
|
||||
// Client-only fields.
|
||||
'originalId',
|
||||
'originalCalendarIds',
|
||||
'accountId',
|
||||
'isShared',
|
||||
'localAccountId',
|
||||
]);
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (dropped.has(firstPointerSegment(key))) continue;
|
||||
patch[key] = value;
|
||||
if (isPerOccurrenceKey(key)) patch[key] = value;
|
||||
}
|
||||
return patch as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
const NOT_IN_OVERRIDE_ENTRIES = new Set<string>([
|
||||
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
|
||||
...OCCURRENCE_REJECTED_KEYS,
|
||||
'recurrenceId',
|
||||
'recurrenceIdTimeZone',
|
||||
...CLIENT_ONLY_KEYS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The fields of an override entry on the base event, as 0.16 has always been
|
||||
* sent them: identity and per-event keys dropped, series keys such as the
|
||||
* organizer kept. 0.16.22 and older store them on the override, and 0.16.19
|
||||
* only accepts an answer to one occurrence that names the organizer.
|
||||
* Stalwart 1.0 refuses them there; see withoutOverrideSeriesKeys.
|
||||
*/
|
||||
function overrideEntryFields(updates: Partial<CalendarEvent>): Record<string, unknown> {
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!NOT_IN_OVERRIDE_ENTRIES.has(firstPointerSegment(key))) fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* `updates` of a base event with the series and per-event keys taken out of
|
||||
* the recurrence overrides it writes, whole entries and pointers into one
|
||||
* alike: what Stalwart 1.0 accepts inside an override. The rest of the
|
||||
* update is left alone.
|
||||
*/
|
||||
export function withoutOverrideSeriesKeys(updates: Record<string, unknown>): Record<string, unknown> {
|
||||
const entry = (value: unknown) => (value && typeof value === 'object'
|
||||
? buildOccurrencePatch(value as Partial<CalendarEvent>)
|
||||
: value);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key === 'recurrenceOverrides' && value && typeof value === 'object') {
|
||||
result[key] = Object.fromEntries(Object.entries(value).map(([id, override]) => [id, entry(override)]));
|
||||
} else if (key.startsWith('recurrenceOverrides/')) {
|
||||
const inEntry = key.split('/').slice(2).join('/');
|
||||
if (!inEntry) result[key] = entry(value);
|
||||
else if (isPerOccurrenceKey(inEntry)) result[key] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* An occurrence of a series that the device expanded itself
|
||||
* (lib/recurrence-expansion.ts), as opposed to one the server handed out.
|
||||
@@ -397,7 +483,7 @@ export function buildFallbackOverridePatch(
|
||||
...kept,
|
||||
start: instance.start,
|
||||
duration: instance.duration,
|
||||
...buildOccurrencePatch(patch),
|
||||
...overrideEntryFields(patch),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,13 +519,24 @@ const NEW_OVERRIDE_COPIED_KEYS = [
|
||||
'locale',
|
||||
'status',
|
||||
'freeBusyStatus',
|
||||
'privacy',
|
||||
'alerts',
|
||||
'organizerCalendarAddress',
|
||||
'participants',
|
||||
'sequence',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Series keys that a new override on the base event carries as well, the way
|
||||
* 0.16 has always been sent it (see overrideEntryFields). An override through
|
||||
* a synthetic id never copies them: they are the series' own, 1.0 refuses a
|
||||
* different value and 0.16.20 to 0.16.23 drop them.
|
||||
*/
|
||||
const NEW_OVERRIDE_SERIES_KEYS = ['privacy', 'organizerCalendarAddress'] as const;
|
||||
|
||||
export interface OverridePatchOptions {
|
||||
/** Include the series keys, for an override written on the base event. */
|
||||
seriesKeys?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `patch` plus the occurrence details a new override needs
|
||||
* (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already
|
||||
@@ -449,11 +546,15 @@ const NEW_OVERRIDE_COPIED_KEYS = [
|
||||
export function withNewOverrideDetails(
|
||||
occurrence: Partial<CalendarEvent>,
|
||||
patch: Partial<CalendarEvent>,
|
||||
options: OverridePatchOptions = {},
|
||||
): Partial<CalendarEvent> {
|
||||
if (!createsOverride(occurrence)) return patch;
|
||||
const names: readonly string[] = options.seriesKeys
|
||||
? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS]
|
||||
: NEW_OVERRIDE_COPIED_KEYS;
|
||||
const source = occurrence as Record<string, unknown>;
|
||||
const details: Record<string, unknown> = {};
|
||||
for (const name of NEW_OVERRIDE_COPIED_KEYS) {
|
||||
for (const name of names) {
|
||||
if (!(name in patch) && source[name] != null) details[name] = source[name];
|
||||
}
|
||||
return { ...details, ...patch } as Partial<CalendarEvent>;
|
||||
@@ -467,7 +568,9 @@ export function withNewOverrideDetails(
|
||||
* single occurrence ("Multiple organizers found in iCalendar object",
|
||||
* 0.16.23), and a pointer inside an override keeps just the patched
|
||||
* participant, dropping the organizer and everyone else from it.
|
||||
* - The organizer: 0.16.19 rejects the override the same way without it.
|
||||
* - For an override on the base event (`seriesKeys`), the organizer: 0.16.19
|
||||
* rejects the override the same way without it. Through a synthetic id it
|
||||
* is left out - it is the series'.
|
||||
* - The sequence (see NEW_OVERRIDE_COPIED_KEYS).
|
||||
* - For a new override, the occurrence's details (`withNewOverrideDetails`).
|
||||
*/
|
||||
@@ -475,17 +578,18 @@ export function buildOccurrenceRsvpPatch(
|
||||
occurrence: Partial<CalendarEvent>,
|
||||
participantId: string,
|
||||
status: Participant['participationStatus'],
|
||||
options: OverridePatchOptions = {},
|
||||
): Partial<CalendarEvent> | null {
|
||||
const participant = occurrence.participants?.[participantId];
|
||||
if (!participant) return null;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (occurrence.sequence != null) patch.sequence = occurrence.sequence;
|
||||
if (occurrence.organizerCalendarAddress) {
|
||||
if (options.seriesKeys && occurrence.organizerCalendarAddress) {
|
||||
patch.organizerCalendarAddress = occurrence.organizerCalendarAddress;
|
||||
}
|
||||
patch.participants = {
|
||||
...occurrence.participants,
|
||||
[participantId]: { ...participant, participationStatus: status },
|
||||
};
|
||||
return withNewOverrideDetails(occurrence, patch as Partial<CalendarEvent>);
|
||||
return withNewOverrideDetails(occurrence, patch as Partial<CalendarEvent>, options);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
}));
|
||||
|
||||
import * as calendarApi from '../../api/calendar';
|
||||
import { JMAPMethodError } from '../../api/jmap-result';
|
||||
import {
|
||||
useCalendarStore,
|
||||
selectVisibleCalendars,
|
||||
@@ -928,11 +929,11 @@ describe('calendar-store', () => {
|
||||
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'declined', undefined, undefined, 'occurrence');
|
||||
|
||||
expect(mockRsvpEvent).not.toHaveBeenCalled();
|
||||
// The whole participants map with the organizer, as Stalwart needs it
|
||||
// on one occurrence; sent with scheduling so the organizer hears back.
|
||||
// The whole participants map, as Stalwart needs it on one occurrence,
|
||||
// without the organizer (the series'); sent with scheduling so the
|
||||
// organizer hears back.
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith('s1', {
|
||||
sequence: 4,
|
||||
organizerCalendarAddress: 'mailto:org@x',
|
||||
participants: { ...participants, me: { ...participants.me, participationStatus: 'declined' } },
|
||||
}, true, undefined);
|
||||
expect(useCalendarStore.getState().events[0].participants?.me.participationStatus).toBe('declined');
|
||||
@@ -1142,3 +1143,161 @@ describe('calendar-store', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Stalwart 1.0 checks what one occurrence carries (jmap/src/calendar_event/
|
||||
// validate.rs). Through a synthetic id, a series property must repeat the
|
||||
// base event's value; inside an override written on the base event it is
|
||||
// refused whatever its value. 0.16.20-0.16.23 drop such keys silently, and
|
||||
// older 0.16 store them on the override.
|
||||
describe('editing one occurrence of a series', () => {
|
||||
const SERIES_PROPERTIES = [
|
||||
'@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId',
|
||||
'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceOverrides', 'recurrenceRule',
|
||||
];
|
||||
// The stored series, organized as Apple Calendar writes it (no CLASS).
|
||||
const BASE: Record<string, unknown> = { id: 'base', uid: 'series@example.com', organizerCalendarAddress: 'mailto:org@example.com' };
|
||||
const participants = {
|
||||
org: { calendarAddress: 'mailto:org@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:me@example.com', participationStatus: 'needs-action' },
|
||||
};
|
||||
|
||||
const seriesKeysIn = (entry: unknown) => Object.keys((entry ?? {}) as object)
|
||||
.filter((key) => SERIES_PROPERTIES.includes(key.split('/')[0]));
|
||||
|
||||
/** CalendarEvent/set updates of a Stalwart 1.0 server holding BASE. */
|
||||
function stalwart10Update() {
|
||||
return async (id: string, patch: Record<string, unknown>) => {
|
||||
if (id !== BASE.id) {
|
||||
for (const key of seriesKeysIn(patch)) {
|
||||
if (JSON.stringify(patch[key] ?? null) !== JSON.stringify(BASE[key] ?? null)) {
|
||||
throw new JMAPMethodError('invalidProperties', 'This property cannot be modified on a single occurrence.');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (key.split('/')[0] !== 'recurrenceOverrides') continue;
|
||||
const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value];
|
||||
const [refused] = entries.flatMap(seriesKeysIn);
|
||||
if (refused) throw new JMAPMethodError('invalidProperties', `${refused} cannot be set inside recurrenceOverrides.`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const serverOccurrence = (partial: Record<string, unknown> = {}) => ({
|
||||
id: 's1', baseEventId: 'base', uid: 'series@example.com', recurrenceId: '2026-03-02T09:00:00',
|
||||
start: '2026-03-02T09:00:00', duration: 'PT1H', title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }],
|
||||
recurrenceOverrides: { '2026-03-02T09:00:00': { title: 'Standup' } }, calendarIds: { 'cal-1': true },
|
||||
organizerCalendarAddress: 'mailto:org@example.com', participants, sequence: 2,
|
||||
...partial,
|
||||
});
|
||||
// Expanded on the device (a server without synthetic ids, or a range the
|
||||
// server would not expand): changes become overrides on the base event.
|
||||
const deviceOccurrence = (partial: Record<string, unknown> = {}) => ({
|
||||
id: 'base:2026-03-02T09:00:00', originalId: 'base', uid: 'series@example.com', recurrenceId: '2026-03-02T09:00:00',
|
||||
start: '2026-03-02T09:00:00', duration: 'PT1H', title: 'Standup', recurrenceRules: [{ frequency: 'weekly' }],
|
||||
recurrenceOverrides: null, calendarIds: { 'cal-1': true },
|
||||
organizerCalendarAddress: 'mailto:org@example.com', participants, sequence: 2,
|
||||
...partial,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useCalendarStore.getState().reset();
|
||||
useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as any });
|
||||
mockQueryEvents.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('sends one occurrence nothing of the series through its synthetic id on Stalwart 1.0', async () => {
|
||||
mockUpdateEvent.mockImplementation(stalwart10Update());
|
||||
useCalendarStore.setState({ events: [serverOccurrence() as any] });
|
||||
|
||||
// What the editor sent before: the organizer in another spelling, which
|
||||
// an alias or a letter case gives.
|
||||
await useCalendarStore.getState().updateEvent('s1', {
|
||||
title: 'Renamed', organizerCalendarAddress: 'mailto:ORG@example.com', calendarIds: { 'cal-1': true },
|
||||
});
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledTimes(1);
|
||||
const [id, patch] = mockUpdateEvent.mock.calls[0];
|
||||
expect(id).toBe('s1');
|
||||
expect(seriesKeysIn(patch)).toEqual([]);
|
||||
expect(patch.title).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('answers one occurrence without the organizer through its synthetic id on Stalwart 1.0', async () => {
|
||||
mockUpdateEvent.mockImplementation(stalwart10Update());
|
||||
useCalendarStore.setState({ events: [serverOccurrence() as any] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'declined', undefined, undefined, 'occurrence');
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateEvent.mock.calls[0][1]).not.toHaveProperty('organizerCalendarAddress');
|
||||
});
|
||||
|
||||
it('writes an override on the base event once more without series keys when Stalwart 1.0 refuses them, and remembers', async () => {
|
||||
mockUpdateEvent.mockImplementation(stalwart10Update());
|
||||
useCalendarStore.setState({ events: [deviceOccurrence() as any] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent('base:2026-03-02T09:00:00', { title: 'Moved' });
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledTimes(2);
|
||||
const [first, second] = mockUpdateEvent.mock.calls.map((call) => call[1].recurrenceOverrides['2026-03-02T09:00:00']);
|
||||
expect(first).toMatchObject({ organizerCalendarAddress: 'mailto:org@example.com' });
|
||||
expect(second).toEqual({
|
||||
start: '2026-03-02T09:00:00', duration: 'PT1H', title: 'Moved', participants, sequence: 2,
|
||||
});
|
||||
|
||||
// The next override goes out without them straight away.
|
||||
mockUpdateEvent.mockClear();
|
||||
await useCalendarStore.getState().rsvpEvent('base:2026-03-02T09:00:00', 'me', 'accepted', undefined, undefined, 'occurrence');
|
||||
expect(mockUpdateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(seriesKeysIn(mockUpdateEvent.mock.calls[0][1].recurrenceOverrides['2026-03-02T09:00:00'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the override payload on 0.16: the organizer, in one write', async () => {
|
||||
mockUpdateEvent.mockImplementation(async () => undefined);
|
||||
useCalendarStore.setState({ events: [deviceOccurrence() as any] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent('base:2026-03-02T09:00:00', 'me', 'tentative', undefined, undefined, 'occurrence');
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith('base', {
|
||||
recurrenceOverrides: {
|
||||
'2026-03-02T09:00:00': {
|
||||
start: '2026-03-02T09:00:00',
|
||||
duration: 'PT1H',
|
||||
title: 'Standup',
|
||||
sequence: 2,
|
||||
organizerCalendarAddress: 'mailto:org@example.com',
|
||||
participants: { ...participants, me: { ...participants.me, participationStatus: 'tentative' } },
|
||||
},
|
||||
},
|
||||
}, true, undefined);
|
||||
});
|
||||
|
||||
it('keeps the 0.16.19 fallback payload for a server that rejects synthetic ids', async () => {
|
||||
mockUpdateEvent
|
||||
.mockRejectedValueOnce(new Error('Updating synthetic ids is not yet supported.'))
|
||||
.mockImplementation(async () => undefined);
|
||||
useCalendarStore.setState({ events: [serverOccurrence({ recurrenceOverrides: { '2026-03-09T09:00:00': {} } }) as any] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted', undefined, undefined, 'occurrence');
|
||||
|
||||
// The synthetic attempt leaves the organizer out, the override has it.
|
||||
expect(mockUpdateEvent.mock.calls[0][1]).not.toHaveProperty('organizerCalendarAddress');
|
||||
expect(mockUpdateEvent.mock.calls[1][0]).toBe('base');
|
||||
expect(mockUpdateEvent.mock.calls[1][1]['recurrenceOverrides/2026-03-02T09:00:00'])
|
||||
.toMatchObject({ organizerCalendarAddress: 'mailto:org@example.com' });
|
||||
});
|
||||
|
||||
it('passes other refusals of an override on the base event through on Stalwart 1.0', async () => {
|
||||
mockUpdateEvent.mockImplementation(async () => {
|
||||
throw new JMAPMethodError('forbidden', 'You do not have access to this calendar.');
|
||||
});
|
||||
useCalendarStore.setState({ events: [deviceOccurrence() as any] });
|
||||
|
||||
await expect(useCalendarStore.getState().updateEvent('base:2026-03-02T09:00:00', { title: 'Moved' }))
|
||||
.rejects.toThrow('You do not have access to this calendar.');
|
||||
expect(mockUpdateEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,10 +42,13 @@ import {
|
||||
buildOccurrencePatch,
|
||||
buildOccurrenceRsvpPatch,
|
||||
isBrowserExpandedOccurrence,
|
||||
isOverrideSeriesKeyRejection,
|
||||
isServerRecurrenceInstance,
|
||||
isSyntheticIdMutationUnsupported,
|
||||
seriesIdOf,
|
||||
withNewOverrideDetails,
|
||||
withoutOverrideSeriesKeys,
|
||||
type OverridePatchOptions,
|
||||
} from '../lib/recurrence-instances';
|
||||
import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection';
|
||||
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
|
||||
@@ -443,33 +446,64 @@ export function resolveMutationTarget(
|
||||
// said it would take them: go straight to the base-event override then.
|
||||
let syntheticIdRejected = false;
|
||||
|
||||
// Set once the server refused series keys inside a recurrence override
|
||||
// (Stalwart 1.0): override writes leave them out from then on.
|
||||
let overrideSeriesKeysRejected = false;
|
||||
|
||||
/**
|
||||
* Patch one server-expanded occurrence through its synthetic id. A server
|
||||
* that predates synthetic-id writes rejects it; the same change is then
|
||||
* written as a recurrence override on the base event instead, and that is
|
||||
* remembered. A change that creates the override carries the occurrence's
|
||||
* details along (`withNewOverrideDetails`).
|
||||
* Update a stored event. Recurrence overrides it writes carry the series keys
|
||||
* 0.16 has always been sent (the organizer; see buildFallbackOverridePatch).
|
||||
* Stalwart 1.0 refuses those inside an override ("organizerCalendarAddress
|
||||
* cannot be set inside recurrenceOverrides."): the update then goes out once
|
||||
* more without them, and so does every later one.
|
||||
*/
|
||||
async function updateBaseEvent(
|
||||
id: string,
|
||||
updates: Record<string, unknown>,
|
||||
sendSchedulingMessages: boolean | undefined,
|
||||
accountId: string | undefined,
|
||||
): Promise<void> {
|
||||
const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides');
|
||||
if (writesOverrides && overrideSeriesKeysRejected) {
|
||||
await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiUpdateEvent(id, updates, sendSchedulingMessages, accountId);
|
||||
} catch (err) {
|
||||
if (!writesOverrides || !isOverrideSeriesKeyRejection(err)) throw err;
|
||||
overrideSeriesKeysRejected = true;
|
||||
await apiUpdateEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch one server-expanded occurrence through its synthetic id, with only
|
||||
* what one occurrence may carry (`buildOccurrencePatch`). A server that
|
||||
* predates synthetic-id writes rejects it; the same change is then written
|
||||
* as a recurrence override on the base event instead, and that is
|
||||
* remembered. `patchFor` builds the change; the base-event override asks it
|
||||
* for the series keys as well.
|
||||
*/
|
||||
async function updateOccurrence(
|
||||
instance: CalendarEvent,
|
||||
syntheticId: string,
|
||||
updates: Partial<CalendarEvent>,
|
||||
patchFor: (options: OverridePatchOptions) => Partial<CalendarEvent>,
|
||||
sendSchedulingMessages: boolean | undefined,
|
||||
accountId: string | undefined,
|
||||
): Promise<void> {
|
||||
const patch = withNewOverrideDetails(instance, buildOccurrencePatch(updates));
|
||||
if (!syntheticIdRejected) {
|
||||
try {
|
||||
await apiUpdateEvent(syntheticId, patch, sendSchedulingMessages, accountId);
|
||||
await apiUpdateEvent(syntheticId, buildOccurrencePatch(patchFor({})), sendSchedulingMessages, accountId);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isSyntheticIdMutationUnsupported(err)) throw err;
|
||||
syntheticIdRejected = true;
|
||||
}
|
||||
}
|
||||
const fallback = buildFallbackOverridePatch(instance, patch);
|
||||
const fallback = buildFallbackOverridePatch(instance, patchFor({ seriesKeys: true }));
|
||||
if (!fallback || !instance.baseEventId) throw new Error('Cannot resolve the occurrence to override');
|
||||
await apiUpdateEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId);
|
||||
await updateBaseEvent(instance.baseEventId, fallback, sendSchedulingMessages, accountId);
|
||||
}
|
||||
|
||||
/** Destroy one server-expanded occurrence; falls back to excluding it on the base event. */
|
||||
@@ -504,9 +538,9 @@ async function updateBrowserOccurrence(
|
||||
sendSchedulingMessages: boolean | undefined,
|
||||
): Promise<void> {
|
||||
const occurrence = target.storeEvent!;
|
||||
const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates));
|
||||
const patch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
|
||||
if (!patch) throw new Error('Cannot resolve the occurrence to override');
|
||||
await apiUpdateEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
|
||||
await updateBaseEvent(target.realId, patch, sendSchedulingMessages, target.accountId);
|
||||
}
|
||||
|
||||
/** Delete one occurrence the device expanded by excluding it on its base event. */
|
||||
@@ -812,11 +846,17 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
||||
|| hasServerOccurrencesOf(get().events, realId, accountId);
|
||||
if (target.isOccurrence && storeEvent) {
|
||||
await updateOccurrence(storeEvent, realId, patch as Partial<CalendarEvent>, schedule, accountId);
|
||||
await updateOccurrence(
|
||||
storeEvent,
|
||||
realId,
|
||||
(options) => withNewOverrideDetails(storeEvent, patch as Partial<CalendarEvent>, options),
|
||||
schedule,
|
||||
accountId,
|
||||
);
|
||||
} else if (target.isBrowserOccurrence) {
|
||||
await updateBrowserOccurrence(target, patch as Partial<CalendarEvent>, schedule);
|
||||
} else {
|
||||
await apiUpdateEvent(realId, patch, schedule, accountId);
|
||||
await updateBaseEvent(realId, patch, schedule, accountId);
|
||||
}
|
||||
set({
|
||||
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
|
||||
@@ -902,10 +942,14 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
||||
|| hasServerOccurrencesOf(get().events, seriesIdOf(storeEvent ?? { id: realId }), accountId);
|
||||
if (occurrence) {
|
||||
const patch = buildOccurrenceRsvpPatch(occurrence, participantId, status);
|
||||
// Through a synthetic id the answer leaves the organizer out (it is the
|
||||
// series'); an override on the base event names it (0.16.19 needs it).
|
||||
const patchFor = (options: OverridePatchOptions) =>
|
||||
buildOccurrenceRsvpPatch(occurrence, participantId, status, options);
|
||||
const patch = patchFor({ seriesKeys: true });
|
||||
if (!patch) throw new Error('Participant not found on this occurrence');
|
||||
if (target.isOccurrence) {
|
||||
await updateOccurrence(occurrence, target.realId, patch, true, accountId);
|
||||
await updateOccurrence(occurrence, target.realId, (options) => patchFor(options) ?? {}, true, accountId);
|
||||
} else {
|
||||
await updateBrowserOccurrence(target, patch, true);
|
||||
}
|
||||
@@ -1144,6 +1188,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
resetCalendarAccessDenied();
|
||||
resetSyntheticIdSupport();
|
||||
syntheticIdRejected = false;
|
||||
overrideSeriesKeysRejected = false;
|
||||
knownMasters.clear();
|
||||
set({
|
||||
calendars: [],
|
||||
|
||||
Reference in New Issue
Block a user