diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts index e24bef26..836abbb3 100644 --- a/src/device-sync/__tests__/calendar/engine.test.ts +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -94,6 +94,43 @@ describe('calendar device edits through the engine', () => { expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]); }); + it('sends an answer to one occurrence in a calendar that grants only RSVP, and reports a refusal', async () => { + const h = real({ accounts: 'withShared' }); + const team = h.server.addCalendar('team', { + name: 'Team events', + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: true, mayShare: false, mayDelete: false }, + }); + h.prefs.calendarSelection[`team/${team}`] = true; + const id = h.server.addEvent('team', { + uid: 'team-weekly', + title: 'Team weekly', + start: '2026-10-05T09:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }, + organizerCalendarAddress: 'mailto:boss@example.net', + participants: { + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' }, + me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + calendarIds: { [team]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const master = h.events().find((e) => e[Events._SYNC_ID] === `team/${id}`)!; + + // Etar answers the Oct 12 occurrence through CONTENT_EXCEPTION_URI. + h.device.user.exceptionViaUri(Number(master._id), Date.UTC(2026, 9, 12, 7), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 }); + const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length; + const before = sets(); + const report = await h.run(CALENDAR_AUTHORITY); + + // The answer was sent (this server takes no RSVP without write rights), the refusal is reported and the row put back. + expect(sets()).toBeGreaterThan(before); + expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `team/${id}`, side: 'upload', type: 'forbidden' })]); + expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `team/${id}`)).toEqual([]); + expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true); + }); + it('keeps a description and location cleared on one occurrence cleared', async () => { const h = real(); const id = h.server.addEvent('a', { diff --git a/src/device-sync/__tests__/calendar/exceptions.test.ts b/src/device-sync/__tests__/calendar/exceptions.test.ts index 7e371a89..965bbf38 100644 --- a/src/device-sync/__tests__/calendar/exceptions.test.ts +++ b/src/device-sync/__tests__/calendar/exceptions.test.ts @@ -225,6 +225,33 @@ describe('calendar planner: recurrence edits made on the device', () => { expect(action.sendSchedulingMessages).toBe(true); }); + it('lets an answer to one occurrence through in a calendar that grants only RSVP, and nothing more', async () => { + const invite = weekly(); + invite.calendarIds = { r: true }; + invite.organizerCalendarAddress = 'mailto:boss@example.net'; + invite.participants = { + boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' }, + me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' }, + }; + const { h, id } = await synced(invite); + // Etar answers Oct 19 through CONTENT_EXCEPTION_URI (a new override)... + const added = h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 }); + // ... and another app answers the existing override of Oct 7 on its row. + const existing = (await h.local('bl'))!.exceptions.find((x) => x.recurrenceId === '2026-10-07T09:00:00')!; + h.fake.table('attendees').forEach((a) => { + if (a.event_id === existing.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 1; + }); + h.fake.user.updateEvent(existing.eventId, {}); + const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)); + type Answer = { participants: Record }; + expect((action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Answer).participants.me.participationStatus).toBe('declined'); + expect((action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as Answer['participants']).me.participationStatus).toBe('accepted'); + expect(action.sendSchedulingMessages).toBe(true); + // Anything more than the answer is still the calendar owner's to change. + h.fake.user.updateEvent(added, { [Events.TITLE]: 'Mine now' }); + expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('revert'); + }); + it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => { const { h, id } = await synced(weekly()); h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' }); diff --git a/src/device-sync/calendar/planner.ts b/src/device-sync/calendar/planner.ts index 31e11f68..4d6157ed 100644 --- a/src/device-sync/calendar/planner.ts +++ b/src/device-sync/calendar/planner.ts @@ -308,9 +308,8 @@ function planUpload(local: LocalEvent, ctx: CalendarContext): Plan { return m && computation.patch[k] === true && ctx.isReadOnly(m[1]); }); if ((readOnly && keys.length) || movedToReadOnly) { - // RSVP is the one change a calendar without write rights may take. - const rsvpOnly = keys.length > 0 && keys.every((k) => /^participants\/[^/]+\/participationStatus$/.test(k)); - if (!(rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) { + // RSVP, to the series or to occurrences, is the one change a calendar without write rights may take. + if (!(computation.rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) { const ops = revertGroup(local, ctx); return ops ? { kind: 'revert', ops } : { kind: 'skip', reason: 'readOnly' }; } diff --git a/src/device-sync/calendar/upload.ts b/src/device-sync/calendar/upload.ts index 4476719e..269924f9 100644 --- a/src/device-sync/calendar/upload.ts +++ b/src/device-sync/calendar/upload.ts @@ -169,6 +169,8 @@ export interface UploadComputation { /** Exception rows whose instance is excluded by the patch, or already was. */ removedExceptions: Set; sendSchedulingMessages: boolean; + /** The patch holds nothing but the user's own answer, to the series or to occurrences (what `mayRSVP` allows). */ + rsvpOnly: boolean; /** Units changed on the device that can't be uploaded (reported when nothing else is). */ dropped: string[]; } @@ -362,6 +364,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo } // Exception rows. + let answersOnly = true; if (!ruleRemoved && shadow.recurrenceRule) { for (const x of local.exceptions) { const units = exceptionChanges(x, local, shadow, calendarId, zone, overrides, removedExceptions, ctx, lossy); @@ -369,6 +372,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo exceptionUnits.set(x.eventId, units.units); overridesChanged = true; rsvp = rsvp || units.rsvp; + answersOnly &&= units.rsvp && !units.others && units.units.size === 1 && units.units.has('attendees'); } } } @@ -377,8 +381,9 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo const notifying = NOTIFYING_UNITS.some((u) => masterUnits.has(u)) || overridesChanged || attendeeOthers; const sendSchedulingMessages = sendsSchedulingMessages(shadow, { kind: 'update', notifying, rsvp, addsAttendees: attendeeOthers }, ctx); + const rsvpOnly = rsvp && answersOnly && !attendeeOthers && [...masterUnits].every((u) => u.startsWith('attendee:')); - return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped }; + return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped, rsvpOnly }; } /** Timing properties that differ from the server's. */ @@ -413,7 +418,7 @@ function exceptionChanges( removed: Set, ctx: CalendarContext, lossy: boolean, -): { units: Set; rsvp: boolean } { +): { units: Set; rsvp: boolean; others?: boolean } { const units = new Set(); const key = x.recurrenceId; if (!key) return { units, rsvp: false }; @@ -439,6 +444,7 @@ function exceptionChanges( const cur = sideOfRow(x); const values: Record = {}; let rsvp = false; + let others = false; const organizer = userIsOrganizer(shadow, ctx); for (const unit of EXCEPTION_COLUMN_UNITS) { @@ -504,6 +510,7 @@ function exceptionChanges( if (result.changes.length) { values.participants = result.participants; rsvp = result.rsvp; + others = result.others; units.add('attendees'); } } @@ -517,12 +524,12 @@ function exceptionChanges( if (existing) { if (units.size) work.props.set(key, { ...(work.props.get(key) ?? {}), ...values }); - return { units, rsvp }; + return { units, rsvp, others }; } // A new override: nothing but its pinned time is still an override (an app saving the instance unchanged). if (!units.size) units.add('new'); work.whole.set(key, newOverride(shadow, key, values)); - return { units, rsvp }; + return { units, rsvp, others }; } /** Units a new exception row inherits from its series when it leaves them empty. */