From c3067b244e4ea0d12eb255cd2a265f256eca4000 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 e83111330328b..a749f4a7378ce 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 0af957823787b..168abde5b220b 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()); }