/** * Server-side recurrence instances ("synthetic ids"). * * `CalendarEvent/query` with `expandRecurrences: true` returns one synthetic * id per occurrence in the range instead of the stored base event. Since * Stalwart 0.16.20 those ids are also accepted by `CalendarEvent/set`: an * update on a synthetic id writes a `recurrenceOverrides` entry for that one * occurrence, a destroy excludes it. Older servers reject both with * `invalidProperties` ("… synthetic ids is not yet supported."), in which * case Bulwark keeps expanding recurrences in the browser * (lib/recurrence-expansion.ts) and patches the base event itself. * * What an expanded occurrence looks like (verified against 0.16.19): * - `id` is synthetic, `baseEventId` is the stored event's id; a * non-recurring event in the range gets a synthetic id too. * - `recurrenceId` / `recurrenceIdTimeZone` are set for occurrences of a * series, `recurrenceRule` / `recurrenceOverrides` are stripped. * - all-day occurrences lose `showWithoutTime` and are stamped with the * request time zone. * - the ids are positional and reshuffle whenever the series' overrides * change, so the visible range has to be refetched after mutating one. */ import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { pointerTokenValue } from '@/lib/jmap/patch-pointer'; import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from '@/lib/recurrence-overrides'; /** * A synthetic id whose event can never exist: expansion 0 of document * `u32::MAX` (`Id::from_parts(1, u32::MAX)` in Stalwart's base32 alphabet). * A pre-0.16.20 server rejects an update on it as `invalidProperties` * before looking anything up; a server that supports synthetic ids answers * `notFound`. Either way nothing is written, which makes it a safe probe. */ export const SYNTHETIC_ID_PROBE = 'h333333'; /** Base-event properties an expanded occurrence needs back (see `hydrateRecurrenceInstances`). */ export const RECURRENCE_BASE_PROPERTIES = [ 'id', 'recurrenceRule', 'excludedRecurrenceRule', 'recurrenceOverrides', 'showWithoutTime', 'timeZone', 'duration', 'title', 'locations', 'description', ] as const; /** * Properties Stalwart refuses on a single occurrence ("This property cannot * be modified on a single occurrence."). They describe the stored event, not * one of its instances. */ export const OCCURRENCE_REJECTED_KEYS = [ 'baseEventId', 'calendarIds', 'isDraft', 'isOrigin', 'utcStart', 'utcEnd', 'useDefaultAlerts', 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', ] as const; /** * Properties of the whole series, plus the client'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; type EventIdentity = Pick & Partial>; /** * True for an occurrence handed out by server-side expansion, i.e. an event * whose (raw) id is synthetic and differs from its base event's id. A base * event fetched directly also carries `baseEventId`, equal to its own id. */ export function isServerRecurrenceInstance(event: EventIdentity | null | undefined): boolean { if (!event?.baseEventId) return false; return event.baseEventId !== (event.originalId ?? event.id); } /** * The store id under which the base event of `instance` would be addressed. * Store ids end with the raw JMAP id (`:`, * `::` or just ``), so swap the trailing raw * synthetic id for the base id and keep whatever namespace prefix is there. */ export function baseEventStoreId(instance: EventIdentity): string | null { if (!isServerRecurrenceInstance(instance) || !instance.baseEventId) return null; const raw = instance.originalId ?? instance.id; if (!instance.id.endsWith(raw)) return instance.baseEventId; return instance.id.slice(0, instance.id.length - raw.length) + instance.baseEventId; } /** * Whether a `CalendarEvent/set` failure is a pre-0.16.20 server refusing a * synthetic id ("Updating/Deleting synthetic ids is not yet supported."). */ export function isSyntheticIdMutationUnsupported(error: unknown): boolean { const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; return /synthetic ids?\b.*\bnot (?:yet )?supported/i.test(message); } function firstPointerSegment(key: string): string { const slash = key.indexOf('/'); return slash === -1 ? key : key.slice(0, slash); } const NOT_PER_OCCURRENCE = new Set([...SERIES_KEYS, ...EVENT_METADATA_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 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): Partial { const patch: Record = {}; for (const [key, value] of Object.entries(updates)) { if (isPerOccurrenceKey(key)) patch[key] = value; } return patch as Partial; } const NOT_IN_OVERRIDE_ENTRIES = new Set([ ...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS, ...OCCURRENCE_REJECTED_KEYS, 'recurrenceId', 'recurrenceIdTimeZone', ]); /** * 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 privacy * and 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): Record { const fields: Record = {}; 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: Partial): Partial { const entry = (value: unknown) => value && typeof value === 'object' ? buildOccurrencePatch(value as Partial) : value; const result: Record = {}; 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 as Partial; } /** * 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 : ''); } type OverrideMap = NonNullable; /** * Given an expanded occurrence, find the key of its entry in the base * event's `recurrenceOverrides`. Normally that is `recurrenceId`; a * pre-0.16.20 server reports a moved occurrence's *new* start as its * recurrenceId, in which case the entry is found by that start instead. */ export function resolveOverrideKey( instance: Pick, overrides: OverrideMap | null | undefined, ): string | null { if (!instance.recurrenceId) return null; if (!overrides || instance.recurrenceId in overrides) return instance.recurrenceId; for (const [key, override] of Object.entries(overrides)) { if (override && override.start === instance.start) return key; } return instance.recurrenceId; } /** `value` as JSON with the members of every object in a fixed order: equal values, equal strings. */ export function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (value && typeof value === 'object') { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`).join(',')}}`; } return JSON.stringify(value ?? null); } /** * An override's participants, each completed from the series participant * with the same id or calendar address. The override decides who takes part; * the series fills what it leaves out. Stalwart records an attendee's reply to * one occurrence as an override naming the organizer under a new id and * without a participation status, which would otherwise show the organizer as * not having answered their own meeting. */ export function mergeOverrideParticipants( series: CalendarEvent['participants'], override: CalendarEvent['participants'], ): CalendarEvent['participants'] { if (!override) return override; const address = (p: CalendarParticipant) => (p.calendarAddress ?? '').toLowerCase(); const from = series ?? {}; const merged: Record = {}; for (const [id, participant] of Object.entries(override)) { const match = id in from ? id : Object.keys(from).find((key) => address(from[key]) && address(from[key]) === address(participant)); const defined = Object.fromEntries(Object.entries(participant).filter(([, v]) => v != null)); merged[match ?? id] = { ...(match ? from[match] : {}), ...defined } as CalendarParticipant; } return merged; } /** The members a PatchObject key points at (RFC 6901 tokens, decoded). */ function pointerPath(key: string): string[] { return key.split('/').map(pointerTokenValue); } /** * A copy of `target` with the member at `path` set to `value` - or removed, * when `value` is null - copying every object on the way. Removing beneath * a member that is not there changes nothing; setting creates it. */ function patchedAt(target: object, path: string[], value: unknown): Record { const copy = (Array.isArray(target) ? [...target] : { ...target }) as Record; const [name, ...rest] = path; if (rest.length === 0) { if (value === null) delete copy[name]; else copy[name] = value; return copy; } const child = copy[name]; if (child !== null && typeof child === 'object') copy[name] = patchedAt(child, rest, value); else if (value !== null) copy[name] = patchedAt({}, rest, value); return copy; } function valueAt(target: unknown, key: string): unknown { let value = target; for (const name of pointerPath(key)) { if (value === null || typeof value !== 'object') return undefined; value = (value as Record)[name]; } return value; } /** * `event` with a recurrence override applied. An override is an RFC 8984 * PatchObject: every key a JSON Pointer into the occurrence, null removing * what it points at. Stalwart 1.0 returns minimal ones such as * `participants/p1/participationStatus`; 0.16 returns whole top-level * values, which apply the same way. Shorter paths go first, so a pointer * refines a value the same patch sets whole, and a whole `participants` map * is completed from the series (mergeOverrideParticipants). */ export function applyOverridePatch>(event: T, patch: Record): T { let result: Record = { ...event }; const keys = Object.keys(patch).sort((a, b) => a.split('/').length - b.split('/').length); for (const key of keys) { const value = patch[key]; if (key === 'participants' && value) { result.participants = mergeOverrideParticipants( result.participants as CalendarEvent['participants'], value as CalendarEvent['participants'], ); } else { result = patchedAt(result, pointerPath(key), value); } } return result as T; } /** * An override entry with `updates` merged in, without keys that are prefixes * of one another (RFC 8984 does not allow those in one PatchObject): a key * replaces whatever the entry patches beneath it, and one beneath a value the * entry sets goes into that value. */ function mergeOverrideEntry(entry: Record, updates: Record): Record { const merged = { ...entry }; for (const [key, value] of Object.entries(updates)) { for (const existing of Object.keys(merged)) { if (existing.startsWith(`${key}/`)) delete merged[existing]; } const holder = Object.keys(merged).find((existing) => key.startsWith(`${existing}/`)); const held = holder === undefined ? undefined : merged[holder]; if (holder !== undefined && held !== null && typeof held === 'object') { merged[holder] = patchedAt(held, pointerPath(key.slice(holder.length + 1)), value); continue; } if (holder !== undefined) delete merged[holder]; merged[key] = value; } return merged; } /** * Stalwart lists an occurrence twice under one id - once as the series * generates it, once from its override - when the override ranks below the * series (no or a lower SEQUENCE). It writes such overrides itself: an * attendee's reply to one occurrence becomes, on the organizer's side, an * override holding little more than that attendee. Collapse each pair into * one occurrence: the fields the override defines (per the base event's * overrides map) from the override, the rest from the series. */ function collapseDuplicateInstances( instances: CalendarEvent[], bases: ReadonlyMap>, ): CalendarEvent[] { const groups = new Map(); for (const instance of instances) { const group = groups.get(instance.id); if (group) group.push(instance); else groups.set(instance.id, [instance]); } if (groups.size === instances.length) return instances; const collapse = (group: CalendarEvent[]): CalendarEvent => { const first = group[0]; const base = first.baseEventId ? bases.get(first.baseEventId) : undefined; const key = base ? resolveOverrideKey(first, base.recurrenceOverrides) : null; const entry = key ? base?.recurrenceOverrides?.[key] as Record | undefined : undefined; if (!entry) { return group.reduce((a, b) => (Object.keys(b).length > Object.keys(a).length ? b : a)); } // The entry may patch members (Stalwart 1.0 returns overrides as // PatchObjects such as `participants/p1/participationStatus`): score each // instance by the members it matches, and take the override's whole // top-level values for what the entry touches. const patchKeys = Object.keys(entry).filter((k) => k !== 'updated'); const score = (instance: CalendarEvent) => patchKeys .filter((k) => stableJson(valueAt(instance, k)) === stableJson(entry[k])) .length; const override = group.reduce((a, b) => (score(b) > score(a) ? b : a)); const series = group.find((instance) => instance !== override) ?? first; const merged: Record = { ...series }; for (const k of new Set(patchKeys.map(firstPointerSegment))) { merged[k] = k === 'participants' ? mergeOverrideParticipants(series.participants, override.participants) : (override as unknown as Record)[k]; } return merged as unknown as CalendarEvent; }; const seen = new Set(); const result: CalendarEvent[] = []; for (const instance of instances) { if (seen.has(instance.id)) continue; seen.add(instance.id); const group = groups.get(instance.id)!; result.push(group.length > 1 ? collapse(group) : instance); } return result; } /** * Complete an occurrence built from a reply stub. 0.16 stored an attendee's * answer to one occurrence as an override holding little more than that * attendee - no SUMMARY, LOCATION or DESCRIPTION - and Stalwart 1.0 builds * such an occurrence from the override alone, so it shows untitled. The * title comes from the series unless the override sets one itself; the * place and description only when the override removes the title, which is * what a stub looks like as 1.0 returns it (`title: null` in the PatchObject). */ function fillReplyStub( occurrence: CalendarEvent, base: Partial, override: Partial | undefined, ): void { if (!occurrence.title && base.title && typeof override?.title !== 'string') { occurrence.title = base.title; } if (override?.title !== null) return; if (occurrence.locations == null && base.locations) occurrence.locations = base.locations; if (!occurrence.description && base.description) occurrence.description = base.description; } function shiftIso(from: string | null | undefined, seconds: number): string | null { if (!from) return null; const ms = Date.parse(from); if (Number.isNaN(ms)) return null; return new Date(ms + seconds * 1000).toISOString(); } /** * Give expanded occurrences the base-event context the rest of the app * expects from an occurrence (and that the client-side expansion used to * copy from the master): the recurrence rules and overrides, the all-day * flag, and - for an override that does not set its own duration - the * inherited duration (RFC 8984 §4.3.4: an override is a patch on the base * event; the server's own computed duration for such overrides has been seen * to be wrong). A title the occurrence lacks comes from the series as well * (see fillReplyStub). Occurrences whose base event is not in `bases` are * returned unchanged. */ export function hydrateRecurrenceInstances( instances: CalendarEvent[], bases: ReadonlyMap>, ): CalendarEvent[] { return collapseDuplicateInstances(instances, bases).map((instance) => { if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance; const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined; if (!base) return instance; const hydrated: CalendarEvent = { ...instance, recurrenceRules: base.recurrenceRules ?? null, excludedRecurrenceRules: base.excludedRecurrenceRules ?? null, recurrenceOverrides: base.recurrenceOverrides ?? null, }; if (base.showWithoutTime) { hydrated.showWithoutTime = true; hydrated.timeZone = base.timeZone ?? null; } const overrideKey = resolveOverrideKey(instance, base.recurrenceOverrides); if (overrideKey) hydrated.recurrenceId = overrideKey; const override = overrideKey ? base.recurrenceOverrides?.[overrideKey] : undefined; fillReplyStub(hydrated, base, override); if (override && override.duration == null && base.duration && hydrated.duration !== base.duration) { hydrated.duration = base.duration; const seconds = parseDurationSeconds(base.duration); if (seconds !== null && !hydrated.showWithoutTime) { hydrated.utcEnd = shiftIso(hydrated.utcStart, seconds) ?? hydrated.utcEnd; } } return hydrated; }); } /** * An occurrence of a series that the browser expanded itself * (lib/recurrence-expansion.ts), as opposed to one the server handed out. * Changes to it are written as a recurrence override on its base event. */ export function isBrowserExpandedOccurrence( event: Pick & Partial>, ): boolean { return !!event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0 && !isServerRecurrenceInstance(event); } type OccurrenceOverrideContext = Pick & Partial>; /** * `occurrence` as seen from `master`, the base event it belongs to, for * building an override patch on the master: the master's overrides map is * the one that counts. */ export function overrideContextOf( occurrence: CalendarEvent, master: Pick, ): CalendarEvent { return { ...occurrence, baseEventId: undefined, recurrenceOverrides: master.recurrenceOverrides ?? null }; } /** * The base event's overrides map as far as `occurrence` knows it, or * `undefined` when it does not. The browser's expansion copies the base * event's map (absent means none); a server occurrence only has it once * hydrated from its base event (`null` then means none). */ function knownOverrides(occurrence: Partial): OverrideMap | null | undefined { if (isServerRecurrenceInstance({ ...occurrence, id: occurrence.id ?? '' })) return occurrence.recurrenceOverrides; return occurrence.recurrenceOverrides ?? null; } /** True when a change to `occurrence` creates its override, false when one exists or it is unknown. */ export function createsOverride(occurrence: Partial): boolean { const overrides = knownOverrides(occurrence); if (overrides === undefined || !occurrence.recurrenceId) return false; const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides); return !!key && !(overrides && key in overrides); } /** * `override` as the entry `key` of the base event's overrides. A * `recurrenceOverrides/` pointer fails ("Patch operation failed") while * the base event has no overrides at all, so the first one is sent as the * whole map - but only when the map is known to be empty. */ function overrideEntryPatch( instance: OccurrenceOverrideContext, key: string, override: Record, ): Partial { const overrides = knownOverrides(instance); if (overrides !== undefined && Object.keys(overrides ?? {}).length === 0) { return { recurrenceOverrides: { [key]: override } } as Partial; } return { [`recurrenceOverrides/${key}`]: override } as Partial; } /** * A change to one occurrence expressed as a recurrence override on its base * event: used for occurrences the browser expanded, and as the fallback for * servers that reject synthetic ids. The existing override (if any) is kept * underneath so a partial patch (a drag that only sets `start`) does not wipe * fields the occurrence already overrides, and `start`/`duration` are pinned * the way the server does it for synthetic-id updates. */ export function buildFallbackOverridePatch( instance: OccurrenceOverrideContext & Pick, patch: Partial, ): Partial | null { const key = resolveOverrideKey(instance, instance.recurrenceOverrides); if (!key) return null; const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record; const { updated: _updated, ...kept } = existing; return overrideEntryPatch(instance, key, mergeOverrideEntry(kept, { start: instance.start, duration: instance.duration, ...overrideEntryFields(patch), })); } /** Destroying one occurrence as an override on its base event: exclude it. */ export function buildFallbackExcludePatch( instance: OccurrenceOverrideContext, ): Partial | null { const key = resolveOverrideKey(instance, instance.recurrenceOverrides); if (!key) return null; return overrideEntryPatch(instance, key, { excluded: true }); } /** * What a new override copies from its occurrence, unless the change sets it * itself. Stalwart stores an override as a VEVENT holding only the override's * own properties, so without these the occurrence loses its title, place, * attendees and reminders - for other CalDAV clients as well as when Stalwart * expands it. `sequence` keeps the override from ranking below the series: * an override with a lower SEQUENCE does not replace its occurrence and * Stalwart then lists that occurrence twice (the organizer's server raises * the sequence of a changed override from this value). */ const NEW_OVERRIDE_COPIED_KEYS = [ 'title', 'description', 'descriptionContentType', 'locations', 'virtualLocations', 'links', 'keywords', 'categories', 'color', 'locale', 'status', 'freeBusyStatus', 'alerts', '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` for an occurrence's existing override on the base event, with the * series' organizer when that override names none - as every editor save * wrote it before 4c20b09c: 0.16.19 refuses an answer to one occurrence * whose override has no organizer (see buildOccurrenceRsvpPatch). Stalwart * 1.0 refuses the key inside an override, and the store then writes the * override once more without series keys; through a synthetic id it is * never sent. */ function withOverrideOrganizer(occurrence: Partial, patch: Partial): Partial { if (!occurrence.organizerCalendarAddress || 'organizerCalendarAddress' in patch || !occurrence.recurrenceId) return patch; const overrides = knownOverrides(occurrence); const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides); const entry = key ? overrides?.[key] : undefined; if (!entry || 'organizerCalendarAddress' in entry) return patch; return { ...patch, organizerCalendarAddress: occurrence.organizerCalendarAddress }; } /** * `patch` plus the occurrence details a new override needs * (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already * has an override - an attendee may not add most of these to the organizer's * - or when that is unknown; an existing override written on the base event * gets the series' organizer if it has none (withOverrideOrganizer). */ export function withNewOverrideDetails( occurrence: Partial, patch: Partial, options: OverridePatchOptions = {}, ): Partial { if (!createsOverride(occurrence)) return options.seriesKeys ? withOverrideOrganizer(occurrence, patch) : patch; const names = options.seriesKeys ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] : NEW_OVERRIDE_COPIED_KEYS; const details: Record = {}; for (const name of names) { if (!(name in patch) && occurrence[name] != null) details[name] = occurrence[name]; } return { ...details, ...patch }; } /** * The patch that answers one occurrence as `participantId`. * * - The whole participants map, with only that participant's status changed. * Stalwart rejects a `participants//participationStatus` pointer on a * 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. * - 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`). */ export function buildOccurrenceRsvpPatch( occurrence: Partial, participantId: string, status: CalendarParticipant['participationStatus'], options: OverridePatchOptions = {}, ): Partial | null { const participant = occurrence.participants?.[participantId]; if (!participant) return null; const patch: Record = {}; if (occurrence.sequence != null) patch.sequence = occurrence.sequence; if (options.seriesKeys && occurrence.organizerCalendarAddress) { patch.organizerCalendarAddress = occurrence.organizerCalendarAddress; } patch.participants = { ...occurrence.participants, [participantId]: { ...participant, participationStatus: status }, }; return withNewOverrideDetails(occurrence, patch as Partial, options); }