Skip to content

Feat/valarm suppression shared calendars - #8006

Closed
howudodat wants to merge 4 commits into
nextcloud:mainfrom
howudodat:feat/valarm-suppression-shared-calendars
Closed

Feat/valarm suppression shared calendars#8006
howudodat wants to merge 4 commits into
nextcloud:mainfrom
howudodat:feat/valarm-suppression-shared-calendars

Conversation

@howudodat

Copy link
Copy Markdown

Owner-Controlled VALARM Suppression for Shared Calendars

Problem

When a Nextcloud calendar is shared with read-write access, VALARM (alarm/reminder) components from the owner's events are transmitted to the sharee via CalDAV. Android calendar apps synced via DAVx5 have no per-calendar notification suppression, causing unwanted alerts on the sharee's device.

Existing behavior: Nextcloud server's CalendarObject::get() (in apps/dav) already strips VALARM for read-only shared calendars. For read-write shares, VALARM is preserved.

Reference: Issue #7498

Solution

Register a SabreDAV plugin from the calendar app via the SabrePluginAddEvent mechanism (NC 28+; this app targets NC 32+). The plugin intercepts CalDAV responses and strips VALARM when the owner has enabled suppression for that share. A new database table stores the per-share preference, and the sharing UI exposes the toggle.

The owner controls the setting: in the calendar edit/share modal, each sharee row has a "suppress alarms" checkbox alongside the existing "can edit" checkbox.


Plan

Architecture

┌─────────────────────────────────────────────────────────────────┐
│  Frontend (Vue/JS)                                              │
│                                                                 │
│  EditCalendarModal.vue                                          │
│    └─ ShareItem.vue  ←── "suppress alarms" checkbox per sharee  │
│         └─ calendars.js store                                   │
│              └─ shareAlarmService.js  ←── axios API calls        │
└─────────────────────┬───────────────────────────────────────────┘
                      │  POST/GET /v1/share-alarm
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  Backend (PHP)                                                  │
│                                                                 │
│  ShareAlarmController.php  ←── resolves calendar URL to ID,     │
│    │                           verifies ownership               │
│    └─ ShareAlarmSettingMapper.php  ←── reads/writes DB          │
│         └─ calendar_share_alarms table                          │
│                                                                 │
│  StripAlarmsPlugin.php  ←── SabreDAV plugin (registered via     │
│    │                        SabrePluginAddEvent)                 │
│    ├─ propFind handler (priority 600) for REPORT responses      │
│    ├─ afterMethod:GET handler for direct GET requests            │
│    └─ ShareAlarmSettingMapper.isSuppressed() with in-mem cache  │
└─────────────────────────────────────────────────────────────────┘

Data Flow

  1. Owner opens EditCalendarModal, sees "suppress alarms" checkbox per sharee
  2. Toggling calls POST /v1/share-alarm → upserts record in calendar_share_alarms
  3. Sharee's CalDAV client (DAVx5) fetches events via REPORT or GET
  4. StripAlarmsPlugin intercepts, checks DB (cached), strips VALARM from ICS data
  5. Sharee receives clean ICS without alarm components → no unwanted notifications

Files Created

File Purpose
lib/Migration/Version5050Date20250701000005.php DB migration: calendar_share_alarms table with calendar_id, principal_uri, suppress_alarms
lib/Db/ShareAlarmSetting.php Entity class for alarm suppression setting
lib/Db/ShareAlarmSettingMapper.php QBMapper with isSuppressed(), findAllByCalendarId(), and cleanup methods
lib/Dav/StripAlarmsPlugin.php SabreDAV plugin: propFind (priority 600) + afterMethod:GET hooks, in-memory cache, VALARM stripping via Sabre\VObject\Reader
lib/Listener/SabrePluginAddListener.php Registers StripAlarmsPlugin via SabrePluginAddEvent
lib/Controller/ShareAlarmController.php API controller: GET /v1/share-alarm and POST /v1/share-alarm, ownership verification, calendar URL→ID resolution via CalDavBackend
src/services/shareAlarmService.js Frontend API service using @nextcloud/axios

Files Modified

File Change
lib/AppInfo/Application.php Registered SabrePluginAddListener for SabrePluginAddEvent
appinfo/routes.php Added GET and POST /v1/share-alarm routes
src/models/calendarShare.js Added suppressAlarms: false to default share object
src/store/calendars.js Added loadShareAlarmSettings and toggleShareAlarmSuppression actions
src/components/AppNavigation/EditCalendarModal/ShareItem.vue Added "suppress alarms" NcCheckboxRadioSwitch, watcher, and updateAlarmSuppression() method
src/components/AppNavigation/EditCalendarModal.vue Loads alarm settings when modal opens for owned calendars with shares

Database Schema

CREATE TABLE calendar_share_alarms (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    calendar_id BIGINT UNSIGNED NOT NULL,   -- internal calendar ID
    principal_uri VARCHAR(255) NOT NULL,     -- e.g. 'principals/users/alice'
    suppress_alarms BOOLEAN NOT NULL DEFAULT FALSE,
    UNIQUE INDEX cal_share_alarm_unique (calendar_id, principal_uri)
);

API Endpoints

GET /apps/calendar/v1/share-alarm?calendarUrl=...

Returns suppression state for all shares of a calendar.

{
    "status": "success",
    "data": {
        "principals/users/alice": true,
        "principals/users/bob": false
    }
}

POST /apps/calendar/v1/share-alarm

Toggles suppression for one share.

{
    "calendarUrl": "/remote.php/dav/calendars/owner/calname/",
    "principalUri": "principals/users/alice",
    "suppressAlarms": true
}

SabreDAV Plugin Details

StripAlarmsPlugin hooks into two interception points:

  1. propFind (priority 600) — For REPORT requests (calendar-multiget, calendar-query). Runs after the CalDAV plugin (priority 150-550) has populated {urn:ietf:params:xml:ns:caldav}calendar-data. Calls propFind->get() to read the ICS, strips VALARM, calls propFind->set() to replace.

  2. afterMethod:GET — For direct GET on .ics files. Reads response->getBodyAsString(), strips VALARM, calls response->setBody().

VALARM stripping follows the same pattern as the server's CalendarObject::removeVAlarms():

$vObject = Reader::read($calendarData);
foreach ($vObject->getComponents() as $subcomponent) {
    unset($subcomponent->VALARM);
}
return $vObject->serialize();

Performance: An in-memory $suppressionCache array (keyed by calendarId:principalUri) avoids repeated DB lookups for objects in the same calendar during a single REPORT response.

CalendarInfo access: Uses reflection as fallback to access the protected calendarInfo property on Sabre's CalendarObject, with a last-resort parent node lookup via the server tree.


Known Caveats

  1. calendarInfo access: The plugin uses reflection to access the protected calendarInfo property on CalendarObject. This should be tested against the actual Nextcloud server version. If getCalendarInfo() becomes public in a future NC version, the reflection fallback becomes unnecessary.

  2. Calendar ID resolution: The controller resolves the calendar URL to an internal ID via CalDavBackend::getCalendarsForUser(). This adds a dependency on the DAV app's backend class (OCA\DAV\CalDAV\CalDavBackend).

  3. Principal URI format mismatch: Frontend uses principal:principals/users/alice (cdav-library format), backend uses principals/users/alice. The store actions strip the principal: prefix before API calls.

  4. propFind->set() after lazy eval: The PropFind::set() behavior after get() triggers lazy evaluation needs verification against the SabreDAV version bundled with NC 32+.


Verification

Unit Tests

  • ShareAlarmSettingMapper: CRUD operations, isSuppressed() returns false for missing records
  • StripAlarmsPlugin: Mock CalendarObject node with calendarInfo, verify VALARM stripping when enabled and no-op when disabled

Manual End-to-End

  1. Owner creates calendar with events containing VALARM
  2. Owner shares calendar with read-write access to another user
  3. Owner opens EditCalendarModal, enables "suppress alarms" for the sharee
  4. Sharee syncs via DAVx5 or fetches via:
    curl -u sharee:pass https://cloud.example.com/remote.php/dav/calendars/sharee/shared-cal/event.ics
  5. Verify the ICS response contains no VALARM components
  6. Toggle off, re-sync, verify VALARM is present again

Peter Carlson added 4 commits February 23, 2026 20:21
Add migration, entity, and mapper for the calendar_share_alarms table.
This stores per-share preferences for whether VALARM components should
be stripped from CalDAV responses for shared calendars.

Ref: nextcloud#7498
Register a SabreDAV plugin via SabrePluginAddEvent that intercepts
CalDAV REPORT and GET responses. When alarm suppression is enabled
for a share, VALARM components are stripped from the ICS data before
it reaches the sharee's client.

Hooks into propFind (priority 600) for REPORT responses and
afterMethod:GET for direct .ics fetches. Uses an in-memory cache
to avoid repeated DB queries within a single request.

Ref: nextcloud#7498
Add ShareAlarmController with GET and POST endpoints at
/v1/share-alarm for reading and toggling per-share alarm
suppression. Resolves calendar DAV URLs to internal IDs
via CalDavBackend and verifies calendar ownership.

Ref: nextcloud#7498
Add "suppress alarms" checkbox to ShareItem in the EditCalendarModal.
The owner can toggle alarm suppression per sharee. Settings are loaded
when the modal opens and persisted via the share-alarm API.

Also fixes a pre-existing Vue 3 migration bug where the isWriteable
watcher fired on mount and toggled permissions unintentionally.
Both checkboxes now use @update:modelValue instead of @update:checked.

Ref: nextcloud#7498
@tcitworld

Copy link
Copy Markdown
Member

TL;DR: doing things properly is much harder

This is not a bad idea, but:

  • the actual issue should be fixed directly more properly. VALARMS should be always removed even with write access, as per the CalendarServer original specs (which Nextcloud calendar sharing is kinda based upon through Sabre)

    Alarms set by the sharer SHOULD NOT be propagated to sharees by default. Clients SHOULD NOT automatically enable triggering of alarms on shared calendars that have just been accepted without confirmation by the user.

  • this means we need users with write-access shared calendars to have their own copy of the calendar data (which requires some kind of huge refactoring) so that they can set their own alarms only for them (see 5.5.4. Per-user Calendar Data in the same document)
  • it should be handled in the apps/dav app in the server repository, not in this app

@SebastianKrupinski

Copy link
Copy Markdown
Contributor

Hi,

I would agree with @tcitworld. This should be fixed in the dav app which is the calendaring backend, but this will require some thought on implementation.

@github-actions

Copy link
Copy Markdown

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

@codecov

codecov Bot commented Mar 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@1358

1358 commented Aug 18, 2026

Copy link
Copy Markdown

A real-world use case, and a note on sequencing

We ran into this on a Nextcloud 34 instance with KDE/Akonadi (kalendarac) clients. Two
calendar patterns coexist in the same deployment and pull in opposite directions:

  1. Project calendars — shared read-write with the team, and reminders should reach
    the people involved.
  2. Managed calendars — someone with delegated access (an assistant, or a team member
    maintaining a resource calendar) has write access to enter and maintain appointments, but
    should not receive the owner's personal reminders.

Today alarm delivery is coupled to the write bit, so neither case is expressible: case 2
needs write access without alarms, case 1 needs alarms without forcing everyone to
read-only. That coupling is, I think, the thing that makes this feel unsolvable — the
permission axis and the notification axis are simply not the same axis.

That is also why I'd gently push back on "VALARMs should always be removed even with write
access" as a standalone change. It fixes case 2 but silently breaks case 1, with nothing
to replace it: for many teams a shared alarm is currently the only notification mechanism
they have. The "always strip" default only becomes safe once sharees can set their own
alarms.

Which is precisely the per-user calendar data the CalDAV sharing extension asks for in
§5.5.4 — and there is already an open server-side ticket for it:
nextcloud/server#19827 "Per-user settings for shared events" (feature: dav,
feature: caldav, 1. to develop). @georgehrke wrote there in 2020: "A pull-request would
be very much appreciated."
As far as I can tell it never received one, simply because the
reporter didn't feel confident taking it on. That ticket is also the one that matches
@SebastianKrupinski's point that this belongs in the dav app rather than here.

Suggested sequencing:

  1. per-user VALARM storage in apps/dav (#19827) — the actual fix;
  2. then "sharer alarms are not propagated by default" becomes safe, because each sharee
    owns their own alarms;
  3. the per-share toggle proposed in this PR becomes unnecessary, rather than a second axis
    to maintain forever.

The non-trivial part of (1) is presumably not splitting the objects but that ETags and sync
tokens have to become per-principal, since the current sync model assumes one canonical
object per URI.

The same coupling exists in the frontend

Worth flagging for scoping: this isn't only a dav-app concern. EditorMixin.js derives a
single isReadOnly flag straight from calendar write rights:

isReadOnly() {
    const calendar = this.calendarsStore.getCalendarById(this.calendarObject.calendarId)
    return !calendar.canCreateObject && !calendar.canModifyObject
}

and that one flag gates the alarm editor — AlarmList.vue has
<AlarmListNew v-if="!isReadOnly" />. Per-user alarms would require splitting it into "may
edit the shared event body" versus "may edit my own per-user properties", the latter being
true for anyone who can see the event at all, including read-only shares. isReadOnly appeared
~124 times across 23 files when I checked main; the work isn't the rename but deciding which
of the two meanings each call site encodes. showSaveButtons() also returns false when
read-only, so there is currently no save path for a personal-only change, and the same
applies to TRANSP in the FreeBusy components.

Not an argument against doing it — just so the estimate covers it.

Happy to contribute test data and client-side observations (KDE/Akonadi, DAVx5) — and if there
is agreement on the design, I'm willing to take on the implementation. #19827 has carried an
explicit invitation for a pull request since 2020; what seems to be missing is not someone to
write it but a shape that has been signed off on first.

@SebastianKrupinski

Copy link
Copy Markdown
Contributor

Closing in favour of #8796

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants