fix: keep attendees' answers on save and deliver occurrence changes

Every save in the event editor rebuilt the participants with new ids
and put every attendee back to needs-action, the user's own answer on
an invitation included. Saves now keep each attendee's id and answer;
new attendees are asked. As on the web, only an organizer who moves an
event (its start, end, all-day or recurrence rule; not a time zone
rewrite, and not a zero-length event) asks the others again.

The series scopes did not fit that:

- "All events" wrote the opened occurrence's start, length, zone and
  participants onto the series: a stretched occurrence stretched every
  one, and one day's decline spread to the whole series. The series now
  keeps its own list and takes only the attendees the user added or
  removed, and its times change only when the occurrence moved (by the
  same delta), its rule only when it changed.
- "This and following" makes a new series, which Stalwart sends only to
  attendees who have not answered: its attendees are asked again.
- Stalwart sends a new occurrence override, the first change to one
  occurrence, only to attendees who have not answered (0.16.6 to 1.0).
  A change that keeps answers now goes out in two writes: the override
  as the occurrence stands without scheduling, then the change as an
  update of it, to the occurrence looked up again. Checked live on 1.0
  and 0.16.23.
- A save of an invitation someone else organizes writes no participants
  while the user left them alone, and never asks for scheduling, so the
  organizer gets no reply for a changed alert. The switch to send
  invitations is hidden there.

Also:

- a reminder change on one occurrence of a series that uses the
  calendar's default alerts turns the defaults off for the series first
  (keeping what it shows under ids of its own); 1.0 kept showing the
  defaults
- all-day and floating occurrences expanded on the device no longer
  carry the master's utcStart/utcEnd, so their reminders are scheduled
  for each day; master instants that are not the master's own (0.16.23's
  year 32548, or the next occurrence's) are ignored
- the editor warns when the attendees and the organizer go over the
  calendars capability's maxParticipantsPerEvent, and a refusal for it
  (Stalwart 1.0 enforces it, answers included) is explained in the
  editor, the series dialogs, the detail sheet and the invitation
  banner, in all languages; a failed save used to fail silently
- refused event creates, updates and answers keep their SetError type
- Calendar/get asks for its properties by name
This commit is contained in:
Linus Rath
2026-09-25 23:50:29 +02:00
parent f490886a8b
commit a562ea59b8
45 changed files with 1474 additions and 78 deletions
+69 -1
View File
@@ -26,7 +26,10 @@ import {
resetTimeZoneChecks,
queryExpandedEvents,
hydrateExpandedOccurrences,
maxParticipantsPerEvent,
rsvpEvent,
} from '../calendar';
import { JMAPSetError, jmapErrorType } from '../jmap-result';
import { useSettingsStore } from '../../stores/settings-store';
describe('toLocalDateTime', () => {
@@ -75,7 +78,14 @@ describe('calendar operations', () => {
expect(result).toEqual(calendars);
expect(mockRequest).toHaveBeenCalledWith(
[
['Calendar/get', { accountId: 'acc-1' }, '0'],
// The properties by name: Stalwart 1.0's unfinished Metadata ones stay out.
['Calendar/get', {
accountId: 'acc-1',
properties: [
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime',
],
}, '0'],
// The synthetic-id probe rides along with the first Calendar/get.
['CalendarEvent/set', { accountId: 'acc-1', update: { h333333: {} } }, 'synthetic-id-probe'],
],
@@ -454,6 +464,64 @@ describe('calendar operations', () => {
});
});
describe('refusals for the participant limit (Stalwart 1.0)', () => {
// 1.0 enforces maxParticipantsPerEvent on every create and update, an
// answer included; 0.16 advertises the limit without enforcing it.
const refusal = {
type: 'invalidProperties',
description: 'An event cannot have more than 20 participants.',
properties: ['participants'],
};
it('keeps the SetError type of a refused create', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { notCreated: { 'new-event': refusal } }, '0']],
});
const error = await createEvent({ title: 'All hands' }, 'cal-1').catch((e) => e);
expect(error).toBeInstanceOf(JMAPSetError);
expect(jmapErrorType(error)).toBe('invalidProperties');
expect(error.message).toBe('An event cannot have more than 20 participants.');
expect(error.properties).toEqual(['participants']);
});
it('keeps the SetError type of a refused update and answer', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { notUpdated: { ev1: refusal } }, '0']],
});
await expect(updateEvent('ev1', { title: 'All hands' })).rejects.toBeInstanceOf(JMAPSetError);
const error = await rsvpEvent('ev1', 'me', 'accepted').catch((e) => e);
expect(error).toBeInstanceOf(JMAPSetError);
expect(error.message).toBe('An event cannot have more than 20 participants.');
});
});
describe('maxParticipantsPerEvent', () => {
const session = (calendars: Record<string, unknown>) => ({
accounts: {
'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': calendars } },
shared: { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
},
});
const client = jmapClient as unknown as { isConnected?: boolean; currentSession?: unknown };
it.each(['0.16.23', '1.0'])('reads the calendars capability of the account, else the primary one (Stalwart %s)', () => {
client.isConnected = true;
client.currentSession = session({ maxParticipantsPerEvent: 20 });
try {
expect(maxParticipantsPerEvent()).toBe(20);
expect(maxParticipantsPerEvent('shared')).toBe(20);
client.currentSession = session({});
expect(maxParticipantsPerEvent()).toBeNull();
} finally {
delete client.isConnected;
delete client.currentSession;
}
});
});
describe('updateEvent', () => {
it('should update event fields', async () => {
mockRequest.mockResolvedValue({
+44 -9
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { JMAPMethodError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import {
@@ -417,11 +417,23 @@ export function resetSyntheticIdSupport(): void {
syntheticIdSupport.clear();
}
// The Calendar properties the app reads, which the default list on 0.16 and
// 1.0 covers. Stalwart 1.0 advertises JMAP Metadata but its handlers are
// still `todo!()`: a get that selects `metadata` or `privateMetadata` panics
// on the server. An explicit list keeps them out even if a later release
// adds them to the default. 0.16.6 knows every one of these.
const CALENDAR_PROPERTIES = [
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime',
];
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const probeKey = syntheticIdProbeKey();
const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey);
const methodCalls: [string, Record<string, unknown>, string][] = [['Calendar/get', { accountId }, '0']];
const methodCalls: [string, Record<string, unknown>, string][] = [
['Calendar/get', { accountId, properties: CALENDAR_PROPERTIES }, '0'],
];
if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId));
// The user's time zone is checked on the way too (calendarTimeZone).
const timeZone = getUserTimeZone();
@@ -443,7 +455,7 @@ export async function getCalendars(): Promise<Calendar[]> {
if (calendarAccessDenied.has(sharedAccountId)) return [];
try {
const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']],
[['Calendar/get', { accountId: sharedAccountId, properties: CALENDAR_PROPERTIES }, '0']],
USING,
);
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
@@ -621,6 +633,28 @@ function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | nu
];
}
/**
* The most participants an event may have, from the account's calendars
* capability (`maxParticipantsPerEvent`; the primary account's when the
* account names none), or null when it is not advertised. Stalwart 1.0
* refuses to create or update an event with more, an answer to an
* invitation included, and counts the organizer; 0.16 advertises the same
* value without enforcing it.
*/
export function maxParticipantsPerEvent(accountId?: string): number | null {
if (!jmapClient.isConnected) return null;
const accounts = jmapClient.currentSession?.accounts;
for (const id of [accountId, jmapClient.accountId]) {
if (!id) continue;
const capability = accounts?.[id]?.accountCapabilities?.[CAPABILITIES.CALENDARS] as
| { maxParticipantsPerEvent?: unknown }
| undefined;
const max = capability?.maxParticipantsPerEvent;
if (typeof max === 'number' && max > 0) return max;
}
return null;
}
/**
* The longest range an expanded query may span, in seconds:
* `maxExpandedQueryDuration` of the account's calendars capability (the
@@ -939,12 +973,13 @@ export async function createEvent(
);
const result = methodResult<{
created?: Record<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }>;
}>(res);
const created = result.created?.['new-event'];
if (!created) {
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
// With its type: the caller names Stalwart 1.0's participant limit.
throw new JMAPSetError(err?.type ?? 'setError', err?.description || err?.type || 'Failed to create event', err?.properties);
}
// The /set response echoes only server-set properties; merge them over the
// submitted payload so the store's optimistic insert keeps recurrence data.
@@ -1013,11 +1048,11 @@ export async function updateEvent(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string; properties?: string[] }> }>(res);
const err = result.notUpdated?.[id];
// 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');
if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to update event', err.properties);
}
export async function deleteEvents(
@@ -1057,9 +1092,9 @@ export async function rsvpEvent(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string; properties?: string[] }> }>(res);
const err = result.notUpdated?.[eventId];
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to send RSVP', err.properties);
}
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).