From d1a833856da409d967033d1cb2b467ac77f80bc9 Mon Sep 17 00:00:00 2001 From: SoleroTG Date: Fri, 28 Aug 2026 16:22:46 +0200 Subject: [PATCH] feat(dav): allow muting reminders per calendar Signed-off-by: SoleroTG Signed-off-by: SebastianKrupinski --- apps/dav/appinfo/info.xml | 2 +- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/CalDAV/CalDavBackend.php | 1 + apps/dav/lib/CalDAV/Reminder/Backend.php | 47 +++++++++++++++- .../lib/CalDAV/Reminder/ReminderService.php | 23 ++++++-- apps/dav/lib/DAV/CustomPropertiesBackend.php | 1 + .../Version3000Date20260828000000.php | 40 +++++++++++++ .../tests/unit/CalDAV/CalDavBackendTest.php | 31 ++++++++++ .../unit/CalDAV/Reminder/BackendTest.php | 56 ++++++++++++++++++- .../CalDAV/Reminder/ReminderServiceTest.php | 51 +++++++++++++++++ 11 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 apps/dav/lib/Migration/Version3000Date20260828000000.php diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 0136e86ba3e7d..9d1429471d35f 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -10,7 +10,7 @@ WebDAV WebDAV endpoint WebDAV endpoint - 3.0.0-dev.0 + 3.0.0-dev.1 agpl owncloud.org DAV diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index fbe1e27bbc5c4..310f1ec83be3d 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -415,6 +415,7 @@ 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php', 'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php', + 'OCA\\DAV\\Migration\\Version3000Date20260828000000' => $baseDir . '/../lib/Migration/Version3000Date20260828000000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 5e0385de4eddc..98a49d46284dd 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -430,6 +430,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php', 'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php', + 'OCA\\DAV\\Migration\\Version3000Date20260828000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20260828000000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index b3b9ebf4238bd..c6e8c9c54f497 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -153,6 +153,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => ['deleted_at', 'int'], '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarm-part-day' => ['default_alarm_pday', 'int'], '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarm-full-day' => ['default_alarm_fday', 'int'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}disable-alarm-notifications' => ['disable_alarm_notifications', 'bool'], ]; /** diff --git a/apps/dav/lib/CalDAV/Reminder/Backend.php b/apps/dav/lib/CalDAV/Reminder/Backend.php index 5ea4566fa4c36..8f5e47cb8ab04 100644 --- a/apps/dav/lib/CalDAV/Reminder/Backend.php +++ b/apps/dav/lib/CalDAV/Reminder/Backend.php @@ -9,8 +9,11 @@ namespace OCA\DAV\CalDAV\Reminder; +use OCA\DAV\DAV\Sharing\Plugin; +use OCA\DAV\Db\PropertyMapper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IDBConnection; +use Sabre\Uri; /** * Class Backend @@ -19,15 +22,23 @@ */ class Backend { + /** + * WebDAV property a sharee sets on their own view of a shared calendar + * to mute reminders, stored via the generic custom-properties backend. + */ + private const string PROPERTY_DISABLE_ALARM_NOTIFICATIONS = '{' . Plugin::NS_NEXTCLOUD . '}disable-alarm-notifications'; + /** * Backend constructor. * * @param IDBConnection $db * @param ITimeFactory $timeFactory + * @param PropertyMapper $propertyMapper */ public function __construct( protected IDBConnection $db, protected ITimeFactory $timeFactory, + private PropertyMapper $propertyMapper, ) { } @@ -39,12 +50,12 @@ public function __construct( */ public function getRemindersToProcess():array { $query = $this->db->getQueryBuilder(); - $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri']) + $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri', 'c.uri', 'c.disable_alarm_notifications']) ->from('calendar_reminders', 'cr') ->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime()))) ->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id')) ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id')) - ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri'); + ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri', 'c.uri', 'c.disable_alarm_notifications'); $stmt = $query->executeQuery(); return array_map( @@ -53,6 +64,35 @@ public function getRemindersToProcess():array { ); } + /** + * Whether the given principal has muted reminders for this reminder's calendar. + * + * The calendar owner's setting is stored directly on the `calendars` row (already + * part of $reminder). A sharee's setting is their own WebDAV property on their view + * of the shared calendar, stored via the generic custom-properties backend. + * + * @param array $reminder A row as returned by getRemindersToProcess() + */ + public function isReminderMutedForPrincipal(array $reminder, string $principalUri): bool { + if ($principalUri === $reminder['principaluri']) { + return $reminder['disable_alarm_notifications']; + } + + [, $shareeUid] = Uri\split($principalUri); + [, $ownerUid] = Uri\split($reminder['principaluri']); + if ($shareeUid === null || $ownerUid === null) { + return false; + } + + $path = 'calendars/' . $shareeUid . '/' . $reminder['uri'] . '_shared_by_' . $ownerUid; + $properties = $this->propertyMapper->findPropertyByPathAndName($shareeUid, $path, self::PROPERTY_DISABLE_ALARM_NOTIFICATIONS); + if (empty($properties)) { + return false; + } + + return $properties[0]->getPropertyvalue() === '1'; + } + /** * Get all scheduled reminders for an event * @@ -192,6 +232,9 @@ private function fixRowTyping(array $row): array { $row['is_relative'] = (bool)$row['is_relative']; $row['notification_date'] = (int)$row['notification_date']; $row['is_repeat_based'] = (bool)$row['is_repeat_based']; + if (array_key_exists('disable_alarm_notifications', $row)) { + $row['disable_alarm_notifications'] = (bool)$row['disable_alarm_notifications']; + } return $row; } diff --git a/apps/dav/lib/CalDAV/Reminder/ReminderService.php b/apps/dav/lib/CalDAV/Reminder/ReminderService.php index ef480a310b2b6..bc163fb9815ed 100644 --- a/apps/dav/lib/CalDAV/Reminder/ReminderService.php +++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php @@ -123,15 +123,30 @@ public function processReminders() :void { continue; } - if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') { + if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'yes') { $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']); } else { $users = []; } - $user = $this->getUserFromPrincipalURI($reminder['principaluri']); - if ($user) { - $users[] = $user; + $ownerUser = $this->getUserFromPrincipalURI($reminder['principaluri']); + if ($ownerUser !== null) { + $users[] = $ownerUser; + } + + // Filter out any users who have muted reminders for this calendar + $users = array_values(array_filter($users, function (IUser $u) use ($reminder): bool { + $principalUri = 'principals/users/' . $u->getUID(); + return !$this->backend->isReminderMutedForPrincipal($reminder, $principalUri); + })); + + if (count($users) === 0) { + $this->logger->debug('Reminder {id} is ignored by all recipient users for calendar {calendarId}', [ + 'id' => $reminder['id'], + 'calendarId' => $reminder['calendar_id'], + ]); + $this->deleteOrProcessNext($reminder, $vevent); + continue; } $userPrincipalEmailAddresses = []; diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index 3a95fe07606ca..101132f1c3d5e 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -195,6 +195,7 @@ public function propFind($path, PropFind $propFind): void { '{http://apple.com/ns/ical/}calendar-order', '{http://apple.com/ns/ical/}calendar-color', '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp', + '{http://nextcloud.com/ns}disable-alarm-notifications', ]; foreach ($customPropertiesForShares as $customPropertyForShares) { diff --git a/apps/dav/lib/Migration/Version3000Date20260828000000.php b/apps/dav/lib/Migration/Version3000Date20260828000000.php new file mode 100644 index 0000000000000..4d53ede02bd1a --- /dev/null +++ b/apps/dav/lib/Migration/Version3000Date20260828000000.php @@ -0,0 +1,40 @@ +getTable('calendars'); + if (!$calendarsTable->hasColumn('disable_alarm_notifications')) { + $calendarsTable->addColumn('disable_alarm_notifications', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + $modified = true; + } + + return $modified ? $schema : null; + } +} diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index a45ee73cd2e06..6fee8f696fd6f 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -2478,4 +2478,35 @@ public function testGetFederatedCalendarByUriHidesPendingCalendar(): void { $this->assertNull($this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal')); } + + public function testDisableAlarmNotificationsProperty(): void { + $calendarId = $this->backend->createCalendar(self::UNIT_TEST_USER, 'DisableAlarmNotificationsTest', []); + + // Default should be false + $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); + $this->assertFalse((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false)); + + // Update to true ('1') + $patch = new PropPatch([ + '{http://nextcloud.com/ns}disable-alarm-notifications' => '1', + ]); + $this->backend->updateCalendar($calendarId, $patch); + $patch->commit(); + + $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); + $this->assertTrue((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false)); + + // Update to false ('0') + $patch = new PropPatch([ + '{http://nextcloud.com/ns}disable-alarm-notifications' => '0', + ]); + $this->backend->updateCalendar($calendarId, $patch); + $patch->commit(); + + $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); + $this->assertFalse((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false)); + + // Clean up + $this->backend->deleteCalendar($calendars[0]['id'], true); + } } diff --git a/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php b/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php index f29b88c5085da..59c55d9bbb3cd 100644 --- a/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php +++ b/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php @@ -10,7 +10,9 @@ namespace OCA\DAV\Tests\unit\CalDAV\Reminder; use OCA\DAV\CalDAV\Reminder\Backend as ReminderBackend; +use OCA\DAV\Db\PropertyMapper; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -25,9 +27,10 @@ protected function setUp(): void { $query->delete('calendar_reminders')->executeStatement(); $query->delete('calendarobjects')->executeStatement(); $query->delete('calendars')->executeStatement(); + $query->delete('properties')->executeStatement(); $this->timeFactory = $this->createMock(ITimeFactory::class); - $this->reminderBackend = new ReminderBackend(self::$realDatabase, $this->timeFactory); + $this->reminderBackend = new ReminderBackend(self::$realDatabase, $this->timeFactory, new PropertyMapper(self::$realDatabase)); $this->createRemindersTestSet(); } @@ -37,6 +40,7 @@ protected function tearDown(): void { $query->delete('calendar_reminders')->executeStatement(); $query->delete('calendarobjects')->executeStatement(); $query->delete('calendars')->executeStatement(); + $query->delete('properties')->executeStatement(); parent::tearDown(); } @@ -129,6 +133,8 @@ public function testGetRemindersToProcess(): void { 'calendardata' => 'Calendar data 123', 'displayname' => 'Displayname 123', 'principaluri' => 'principals/users/user001', + 'uri' => 'personal', + 'disable_alarm_notifications' => false, ]; $expected2 = [ 'calendar_id' => 1, @@ -146,6 +152,8 @@ public function testGetRemindersToProcess(): void { 'calendardata' => 'Calendar data 123', 'displayname' => 'Displayname 123', 'principaluri' => 'principals/users/user001', + 'uri' => 'personal', + 'disable_alarm_notifications' => false, ]; $this->assertEqualsCanonicalizing([$rows[0],$rows[1]], [$expected1,$expected2]); @@ -252,6 +260,50 @@ public function testUpdateReminder(): void { $this->assertEquals((int)$row['notification_date'], 123700); } + public function testIsReminderMutedForPrincipalUsesCalendarColumnForOwner(): void { + $reminder = [ + 'principaluri' => 'principals/users/user001', + 'uri' => 'personal', + 'disable_alarm_notifications' => true, + ]; + + $this->assertTrue($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user001')); + } + + public function testIsReminderMutedForPrincipalIsFalseForShareeWithoutProperty(): void { + $reminder = [ + 'principaluri' => 'principals/users/user001', + 'uri' => 'personal', + 'disable_alarm_notifications' => false, + ]; + + $this->assertFalse($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user002')); + } + + public function testIsReminderMutedForPrincipalReadsShareePropertyOverride(): void { + $reminder = [ + 'principaluri' => 'principals/users/user001', + 'uri' => 'personal', + // Owner has not muted their own calendar ... + 'disable_alarm_notifications' => false, + ]; + // ... but the sharee muted their own view of it. + $path = 'calendars/user002/personal_shared_by_user001'; + + $query = self::$realDatabase->getQueryBuilder(); + $query->insert('properties') + ->values([ + 'userid' => $query->createNamedParameter('user002'), + 'propertypath' => $query->createNamedParameter($path), + 'propertyname' => $query->createNamedParameter('{http://nextcloud.com/ns}disable-alarm-notifications'), + 'propertyvalue' => $query->createNamedParameter('1'), + 'valuetype' => $query->createNamedParameter(1, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + + $this->assertTrue($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user002')); + } + private function createRemindersTestSet(): void { $query = self::$realDatabase->getQueryBuilder(); $query->insert('calendars') @@ -259,6 +311,7 @@ private function createRemindersTestSet(): void { 'id' => $query->createNamedParameter(1), 'principaluri' => $query->createNamedParameter('principals/users/user001'), 'displayname' => $query->createNamedParameter('Displayname 123'), + 'uri' => $query->createNamedParameter('personal'), ]) ->executeStatement(); @@ -268,6 +321,7 @@ private function createRemindersTestSet(): void { 'id' => $query->createNamedParameter(99), 'principaluri' => $query->createNamedParameter('principals/users/user002'), 'displayname' => $query->createNamedParameter('Displayname 99'), + 'uri' => $query->createNamedParameter('personal'), ]) ->executeStatement(); diff --git a/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php b/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php index f07d508e61978..0fde892b53dba 100644 --- a/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php @@ -774,4 +774,55 @@ public function testProcessReminders():void { $this->reminderService->processReminders(); } + + public function testProcessRemindersSkipsMutedRecipient():void { + $reminder = [ + 'id' => 1, + 'calendar_id' => 1337, + 'object_id' => 42, + 'uid' => 'wej2z68l9h', + 'is_recurring' => false, + 'recurrence_id' => 1465430400, + 'is_recurrence_exception' => false, + 'event_hash' => '5c70531aab15c92b52518ae10a2f78a4', + 'alarm_hash' => 'de919af7429d3b5c11e8b9d289b411a6', + 'type' => 'EMAIL', + 'is_relative' => true, + 'notification_date' => 1465429500, + 'is_repeat_based' => false, + 'calendardata' => self::CALENDAR_DATA, + 'displayname' => 'Displayname 123', + 'principaluri' => 'principals/users/user001', + ]; + + $this->backend->expects($this->once()) + ->method('getRemindersToProcess') + ->willReturn([$reminder]); + + $this->notificationProviderManager->expects($this->once()) + ->method('hasProvider') + ->with('EMAIL') + ->willReturn(true); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user001'); + $this->userManager->expects($this->once()) + ->method('get') + ->with('user001') + ->willReturn($user); + + $this->backend->expects($this->once()) + ->method('isReminderMutedForPrincipal') + ->with($reminder, 'principals/users/user001') + ->willReturn(true); + + $this->notificationProviderManager->expects($this->never()) + ->method('getProvider'); + + $this->backend->expects($this->once()) + ->method('removeReminder') + ->with(1); + + $this->reminderService->processReminders(); + } }