From 05a46d279e845b775c91077499cf3478617e7e93 Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Fri, 22 May 2026 12:08:11 +0000 Subject: [PATCH] fix(dav): keep cancelled occurrence in iTip REQUEST so attendees keep it cancelled When an organizer cancels a single occurrence of a recurring event, the broker emits a per-instance CANCEL plus a REQUEST for the attendee's remaining instances. The REQUEST omitted the cancelled instance, but processMessageRequest replaces all components of the attendee's stored object, so it dropped the CANCELLED override the CANCEL had just added and the occurrence reappeared as a normal event on the attendee's calendar. Keep the cancelled instance in the REQUEST so the override survives the component replace and the occurrence stays cancelled for attendees. Refs: https://github.com/nextcloud/calendar/issues/6655 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nico Donath --- apps/dav/lib/CalDAV/TipBroker.php | 12 +++++------- apps/dav/tests/unit/CalDAV/TipBrokerTest.php | 6 ++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php index e23d81f44c501..142f14ff5d11f 100644 --- a/apps/dav/lib/CalDAV/TipBroker.php +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -319,13 +319,11 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, } return $messages; } - // detect if a new cancelled instance was created - $cancelledNewInstances = []; + // detect if a new cancelled instance was created and send a CANCEL for it if (isset($oldEventInfo['instances'])) { $instancesDelta = array_diff_key($eventInfo['instances'], $oldEventInfo['instances']); foreach ($instancesDelta as $id => $instance) { if ($instance->STATUS?->getValue() === 'CANCELLED') { - $cancelledNewInstances[] = $id; foreach ($eventInfo['attendees'] as $attendee) { $messages[] = $this->generateMessage( [$id => $instance], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template @@ -366,10 +364,10 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, // otherwise any created or modified instances will be sent as REQUEST $instances = array_intersect_key($eventInfo['instances'], array_flip(array_keys($eventInfo['attendees'][$attendee]['instances']))); - // Remove already-cancelled new instances from REQUEST - if (!empty($cancelledNewInstances)) { - $instances = array_diff_key($instances, array_flip($cancelledNewInstances)); - } + // Keep newly cancelled instances IN the REQUEST. processMessageRequest replaces all + // components of the attendee's stored object with the ones from the message, so a + // REQUEST that omitted the cancelled instance would drop the CANCELLED override that + // the accompanying CANCEL added and the occurrence would reappear as a normal event. // Skip if no instances left to send if (empty($instances)) { diff --git a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php index 9d4c27c9bfbc0..8fd3a9bdebc1c 100644 --- a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php +++ b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php @@ -303,6 +303,12 @@ public function testParseEventForOrganizerCreatedInstanceCancelled(): void { $this->assertEquals($mutatedCalendar->VEVENT[1]->ATTENDEE[0]->getValue(), $messages[0]->recipient); $this->assertCount(1, $messages[0]->message->VEVENT); $this->assertEquals('20240715T080000', $messages[0]->message->VEVENT->{'RECURRENCE-ID'}->getValue()); + // the REQUEST keeps the cancelled instance, otherwise processMessageRequest (which replaces + // all components) would drop the CANCELLED override on the attendee's copy (issue #6655) + $this->assertEquals('REQUEST', $messages[1]->method); + $this->assertCount(2, $messages[1]->message->VEVENT); + $this->assertEquals('20240715T080000', $messages[1]->message->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertEquals('CANCELLED', $messages[1]->message->VEVENT[1]->STATUS->getValue()); }