mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
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:
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user