From 6ed415e6bc4fd6f294c92e66c3fcfe059ba4f47f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 26 May 2026 01:29:56 +0200 Subject: [PATCH] feat(notifications): schema-driven notification engine + field-change condition Lands the OpenRegister notification engine that the fleet apps consume: - Rules sourced ONLY from the schema annotation x-openregister-notifications, evaluated at dispatch from the already-loaded schema (no rule table). - Override-only per-user preferences via Nextcloud user-config (NotificationPreferenceService + NotificationPreferencesController: GET/PUT /api/notification-preferences); absence falls through to the schema default (zero-migration). - AnnotationNotifier renders object_created/object_updated/object_transitioned with nl/en i18n + object-detail action link; Notifier keeps configuration_update_available (mutually exclusive by subject). - Per-recipient delivery gate (schema-default merged with user override). - updated trigger gains an optional non-numeric field-change condition (changed / equals (+from)), fail-closed when old/new data absent, back-compatible for condition-less rules. - Deprecates the NotificationSubscription table/controller with a one-shot idempotent repair migrating rows to user-config overrides. Includes the archived notificatie-engine delta sync, the notification-updated-field-change-condition change, and a queued openregister-system-notifications change request. --- CHANGELOG.md | 1 + appinfo/info.xml | 1 + appinfo/routes.php | 6 +- docs/features/webhooks-and-notifications.md | 17 ++ l10n/nl.js | 4 + l10n/nl.json | 4 + .../NotificationPreferencesController.php | 181 ++++++++++++ .../NotificationSubscriptionsController.php | 12 + lib/Db/NotificationSubscription.php | 4 + lib/Db/NotificationSubscriptionMapper.php | 5 + .../AnnotationNotificationListener.php | 14 +- lib/Notification/AnnotationNotifier.php | 101 +++++-- lib/Notification/Notifier.php | 12 +- ...eNotificationSubscriptionsToUserConfig.php | 226 +++++++++++++++ .../AnnotationNotificationDispatcher.php | 260 ++++++++++++++++-- .../NotificationPreferenceService.php | 258 +++++++++++++++++ .../.openspec.yaml | 2 + .../design.md | 119 ++++++++ .../proposal.md | 50 ++++ .../specs/notificatie-engine/spec.md | 179 ++++++++++++ .../tasks.md | 48 ++++ .../.openspec.yaml | 2 + .../design.md | 51 ++++ .../proposal.md | 34 +++ .../specs/notificatie-engine/spec.md | 77 ++++++ .../tasks.md | 35 +++ .../.openspec.yaml | 2 + .../design.md | 80 ++++++ .../proposal.md | 111 ++++++++ .../specs/notificatie-engine/spec.md | 95 +++++++ .../tasks.md | 41 +++ openspec/specs/notificatie-engine/spec.md | 154 ++++++++--- .../NotificationPreferencesControllerTest.php | 120 ++++++++ .../Notification/AnnotationNotifierTest.php | 135 +++++++++ ...ificationSubscriptionsToUserConfigTest.php | 63 +++++ .../AnnotationNotificationDispatcherTest.php | 155 ++++++++++- .../NotificationPreferenceServiceTest.php | 172 ++++++++++++ 37 files changed, 2741 insertions(+), 90 deletions(-) create mode 100644 lib/Controller/NotificationPreferencesController.php create mode 100644 lib/Repair/MigrateNotificationSubscriptionsToUserConfig.php create mode 100644 lib/Service/Notification/NotificationPreferenceService.php create mode 100644 openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/design.md create mode 100644 openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/proposal.md create mode 100644 openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/specs/notificatie-engine/spec.md create mode 100644 openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/tasks.md create mode 100644 openspec/changes/notification-updated-field-change-condition/.openspec.yaml create mode 100644 openspec/changes/notification-updated-field-change-condition/design.md create mode 100644 openspec/changes/notification-updated-field-change-condition/proposal.md create mode 100644 openspec/changes/notification-updated-field-change-condition/specs/notificatie-engine/spec.md create mode 100644 openspec/changes/notification-updated-field-change-condition/tasks.md create mode 100644 openspec/changes/openregister-system-notifications/.openspec.yaml create mode 100644 openspec/changes/openregister-system-notifications/design.md create mode 100644 openspec/changes/openregister-system-notifications/proposal.md create mode 100644 openspec/changes/openregister-system-notifications/specs/notificatie-engine/spec.md create mode 100644 openspec/changes/openregister-system-notifications/tasks.md create mode 100644 tests/Unit/Controller/NotificationPreferencesControllerTest.php create mode 100644 tests/Unit/Notification/AnnotationNotifierTest.php create mode 100644 tests/Unit/Repair/MigrateNotificationSubscriptionsToUserConfigTest.php create mode 100644 tests/Unit/Service/Notification/NotificationPreferenceServiceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c388f7e9ed..261792968b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- **Schema-declared in-app/push notifications now render, with override-only per-user preferences.** Object-lifecycle notifications declared in a schema's `x-openregister-notifications` (`object_created` / `object_updated` / `object_transitioned`) now render in the Nextcloud bell via `AnnotationNotifier` — localised nl/en, with a primary action deep-linking to the object (a schema's custom per-locale `subject` wins, else a canonical string); `Notifier` keeps `configuration_update_available` and the two are mutually exclusive by subject so the manager never double-renders. Push needs no extra code (`notify_push` intercepts the same `IManager::notify()`). Notification **rules** live ONLY in the schema annotation (no rule table) and are evaluated at dispatch from the always-loaded schema. Per-user **preferences** are override-only Nextcloud user-config values (`notification_pref//`) exposed via `GET`/`PUT /api/notification-preferences`: a stored override only flips the schema default (on/off, optionally narrowing channels) for one `(schema, notification)` pair, and absence falls through to the default — so adding a schema or a new notification works with **zero migration**. The dispatcher resolves `schema-default ⊕ user-override` **per recipient** before delivering the in-app/push channel, skipping recipients who opted out without affecting others. The legacy per-`(register, schema)` `NotificationSubscription` table + controller are **deprecated**, with a one-shot repair step migrating existing rows to user-config overrides. (`notification-schema-rules-and-userconfig-prefs`) - **EML (`message/rfc822`) support in `TextExtractionService`.** Two output paths share an underlying `zbateson/mail-mime-parser` invocation: (1) a flat plain-text path used by `extractFile` for entity detection — header block (`From` / `To` / `Cc` / `Subject` / `Date`), blank line, body (`text/plain` preferred over `text/html`-stripped-to-text), attachments listed under `--- Attachment: ---` markers; nested EML attachments are inlined via recursive flattening; (2) a new public `TextExtractionService::parseEmlStructured(File): EmlStructure` that returns headers + `EmlBody` (`plainText`, `html`) + array of `EmlAttachment` (decoded binary bytes — not the base64 transport string — plus filename / MIME / inline / contentId / `nestedEml`). Recursion is capped at depth 3 (root = depth 0; deeper `message/rfc822` attachments expose an `EmlAttachment` shell with `nestedEml = null`). `parseEmlStructured` MUST throw `EmlParseException` on irrecoverable malformed input — consumers (notably DocuDesk's `eml-pdf-assembly`) drive their fallback paths via exception propagation. Non-UTF-8 body parts are transcoded via `mb_detect_encoding` + `mb_convert_encoding`. Filename resolution: `Content-Disposition` `filename` → `Content-Type` `name` → generated `attachment-` (1-indexed). Per ADR-005, parser-failure log lines are PII-sanitised — addresses, quoted strings, and angle-bracketed values are replaced with `` before logging. New dependency: `zbateson/mail-mime-parser:^3.0`. (`text-extraction-eml`) ### Behaviour changes diff --git a/appinfo/info.xml b/appinfo/info.xml index dc183e8ce8..a8ef283e2f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -73,6 +73,7 @@ Vrij en open source onder de EUPL-licentie. OCA\OpenRegister\Repair\RegisterRiskLevelMetadata OCA\OpenRegister\Repair\LogDanglingLinkedTypes + OCA\OpenRegister\Repair\MigrateNotificationSubscriptionsToUserConfig OCA\OpenRegister\Repair\RegisterRiskLevelMetadata diff --git a/appinfo/routes.php b/appinfo/routes.php index 71452fad5b..dd0b40b017 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -624,10 +624,14 @@ ['name' => 'auditTrail#destroyMultiple', 'url' => '/api/audit-trails', 'verb' => 'DELETE'], // Notification History — read-only audit trail of every dispatch. ['name' => 'notificationHistory#index', 'url' => '/api/notification-history', 'verb' => 'GET'], - // Notification Subscriptions — per-user (register, schema) opt-in surface. + // Notification Subscriptions — DEPRECATED per-user (register, schema) opt-in surface. + // Superseded by override-only Notification Preferences below; kept during the deprecation window. ['name' => 'notificationSubscriptions#index', 'url' => '/api/notification-subscriptions', 'verb' => 'GET'], ['name' => 'notificationSubscriptions#create', 'url' => '/api/notification-subscriptions', 'verb' => 'POST'], ['name' => 'notificationSubscriptions#destroy', 'url' => '/api/notification-subscriptions', 'verb' => 'DELETE'], + // Notification Preferences — override-only, per-(schema, notification) user preferences. + ['name' => 'notificationPreferences#index', 'url' => '/api/notification-preferences', 'verb' => 'GET'], + ['name' => 'notificationPreferences#update', 'url' => '/api/notification-preferences', 'verb' => 'PUT'], // Search Trails - specific routes first, then general ones. ['name' => 'searchTrail#index', 'url' => '/api/search-trails', 'verb' => 'GET'], ['name' => 'searchTrail#statistics', 'url' => '/api/search-trails/statistics', 'verb' => 'GET'], diff --git a/docs/features/webhooks-and-notifications.md b/docs/features/webhooks-and-notifications.md index 32866a504a..5a5eb5315a 100644 --- a/docs/features/webhooks-and-notifications.md +++ b/docs/features/webhooks-and-notifications.md @@ -174,6 +174,23 @@ Rules live on the schema under `configuration['x-openregister-notifications']`. } ``` +#### Rendering + +In-app subjects are rendered by `AnnotationNotifier` (`object_created` / `object_updated` / `object_transitioned`), localised nl/en, with a primary action linking to the object detail view. A schema's per-locale `subject` wins; otherwise a canonical localised string is used. `Notifier` keeps `configuration_update_available` — the two notifiers are mutually exclusive by subject. Push needs no extra code: `notify_push` auto-intercepts the same `INotificationManager::notify()` call. + +### User Notification Preferences + +Notification **rules** live ONLY in the schema annotation (no rule table) — the dispatcher evaluates them from the already-loaded schema at dispatch time. Per-user **preferences** are stored as **override-only** values in Nextcloud per-user app config under the `openregister` app, keyed `notification_pref//`. An override only flips the schema-declared default (on/off, optionally narrowing channels) for one `(schema, notification)` pair; when a user has no override the schema default applies. This gives a **zero-migration** property: adding a schema — or a notification to an existing schema — works immediately with no per-user backfill. + +Before delivering the in-app/push channel, the dispatcher resolves `schema-default ⊕ user-override` **per recipient** and skips recipients whose effective value is off (one user's override never affects another's delivery). + +| Endpoint | Description | +|----------|-------------| +| `GET /api/notification-preferences` | Effective notifications for the current user (every notification their accessible schemas declare, merged with their overrides, each tagged `schema-default` or `user-override`). | +| `PUT /api/notification-preferences` | Record (`{schema, notification, enabled, channels?}`) or clear (`{schema, notification, reset: true}`) a single override for the current user only. | + +> The earlier per-`(register, schema)` `NotificationSubscription` table + controller are **deprecated**; existing rows are migrated to user-config overrides by a one-shot repair step and the table is scheduled for removal. + ### VNG Notificaties API Compliance For Dutch government interoperability, webhook payloads can be formatted according to the VNG Notificaties API standard via a Twig mapping configuration. This enables OpenRegister to act as a notificatiecomponent in a ZGW API landscape. diff --git a/l10n/nl.js b/l10n/nl.js index c4c414f923..365c55217b 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,10 @@ OC.L10N.register( "openregister", { + "Object \"%1$s\" created in register \"%2$s\"" : "Object \"%1$s\" aangemaakt in register \"%2$s\"", + "Object \"%1$s\" updated in register \"%2$s\"" : "Object \"%1$s\" bijgewerkt in register \"%2$s\"", + "Object \"%1$s\" assigned to you in register \"%2$s\"" : "Object \"%1$s\" aan je toegewezen in register \"%2$s\"", + "object" : "object", "📄 Object Serialization" : "📄 Objectserialisatie", "🔢 Vectorization Settings" : "🔢 Vectorisatie-instellingen", "💰 View Selection (Cost Optimization)" : "💰 Weergaveselectie (kostenoptimalisatie)", diff --git a/l10n/nl.json b/l10n/nl.json index 4dc40c6520..5b7edf4699 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,9 @@ { "translations": { + "Object \"%1$s\" created in register \"%2$s\"": "Object \"%1$s\" aangemaakt in register \"%2$s\"", + "Object \"%1$s\" updated in register \"%2$s\"": "Object \"%1$s\" bijgewerkt in register \"%2$s\"", + "Object \"%1$s\" assigned to you in register \"%2$s\"": "Object \"%1$s\" aan je toegewezen in register \"%2$s\"", + "object": "object", "📄 Object Serialization": "📄 Objectserialisatie", "🔢 Vectorization Settings": "🔢 Vectorisatie-instellingen", "💰 View Selection (Cost Optimization)": "💰 Weergaveselectie (kostenoptimalisatie)", diff --git a/lib/Controller/NotificationPreferencesController.php b/lib/Controller/NotificationPreferencesController.php new file mode 100644 index 0000000000..1af132f7a1 --- /dev/null +++ b/lib/Controller/NotificationPreferencesController.php @@ -0,0 +1,181 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +class NotificationPreferencesController extends Controller +{ + /** + * Constructor. + * + * @param string $appName App name. + * @param IRequest $request Request. + * @param NotificationPreferenceService $preferenceService Override-only preference resolver. + * @param IUserSession $userSession Current-user session. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly NotificationPreferenceService $preferenceService, + private readonly IUserSession $userSession + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Return the effective notifications (schema default ⊕ user override) + * for the current user. + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function index(): JSONResponse + { + $userId = $this->resolveUserId(); + if ($userId === null) { + return new JSONResponse(data: ['error' => 'Authentication required'], statusCode: 401); + } + + $items = $this->preferenceService->getEffectiveForUser(userId: $userId); + return new JSONResponse(data: ['results' => $items, 'total' => count($items)]); + }//end index() + + /** + * Record or clear a single `(schema, notification)` override for the + * current user only. + * + * Body: `{ schema, notification, enabled?, channels?, reset? }`. + * `reset: true` removes the override so the schema default applies. + * + * @return JSONResponse + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function update(): JSONResponse + { + $userId = $this->resolveUserId(); + if ($userId === null) { + return new JSONResponse(data: ['error' => 'Authentication required'], statusCode: 401); + } + + $params = $this->request->getParams(); + + $schema = $this->nonEmptyString(value: ($params['schema'] ?? null)); + $notification = $this->nonEmptyString(value: ($params['notification'] ?? null)); + if ($schema === null || $notification === null) { + return new JSONResponse( + data: ['error' => 'Both "schema" and "notification" are required'], + statusCode: 422 + ); + } + + // Clearing the override restores the schema default. + if (($params['reset'] ?? false) === true || ($params['reset'] ?? null) === 'true') { + $this->preferenceService->setOverride( + userId: $userId, + schemaSlug: $schema, + notificationKey: $notification, + override: null + ); + return new JSONResponse( + data: ['schema' => $schema, 'notification' => $notification, 'override' => null] + ); + } + + $override = ['enabled' => (bool) ($params['enabled'] ?? true)]; + if (isset($params['channels']) === true && is_array($params['channels']) === true) { + $override['channels'] = $params['channels']; + } + + $this->preferenceService->setOverride( + userId: $userId, + schemaSlug: $schema, + notificationKey: $notification, + override: $override + ); + + return new JSONResponse( + data: [ + 'schema' => $schema, + 'notification' => $notification, + 'override' => $this->preferenceService->getOverride( + userId: $userId, + schemaSlug: $schema, + notificationKey: $notification + ), + ] + ); + }//end update() + + /** + * Resolve the current user's UID, or null when anonymous. + * + * @return string|null + */ + private function resolveUserId(): ?string + { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + return $user->getUID(); + }//end resolveUserId() + + /** + * Coerce a request value to a non-empty string, or null. + * + * @param mixed $value Input. + * + * @return string|null + */ + private function nonEmptyString(mixed $value): ?string + { + if (is_string($value) === false || $value === '') { + return null; + } + + return $value; + }//end nonEmptyString() +}//end class diff --git a/lib/Controller/NotificationSubscriptionsController.php b/lib/Controller/NotificationSubscriptionsController.php index 59a29cbe26..adbc74ca6e 100644 --- a/lib/Controller/NotificationSubscriptionsController.php +++ b/lib/Controller/NotificationSubscriptionsController.php @@ -44,6 +44,18 @@ use OCA\OpenRegister\Db\SchemaMapper; use OCP\IUserSession; +/** + * DEPRECATED per-user (register, schema) notification subscription surface. + * + * Superseded by the override-only model: rules live in the schema annotation + * `x-openregister-notifications` and per-user preferences are override-only + * Nextcloud user-config values served by NotificationPreferencesController. + * Existing rows are migrated by the MigrateNotificationSubscriptionsToUserConfig + * repair step; this controller keeps responding during the deprecation window + * and is scheduled for removal. + * + * @deprecated Use NotificationPreferencesController (override-only user-config). + */ class NotificationSubscriptionsController extends Controller { /** diff --git a/lib/Db/NotificationSubscription.php b/lib/Db/NotificationSubscription.php index eb2b65498a..344ed75192 100644 --- a/lib/Db/NotificationSubscription.php +++ b/lib/Db/NotificationSubscription.php @@ -35,6 +35,10 @@ * @method int|null getSchemaId() * @method void setCreated(\DateTime $created) * @method \DateTime|null getCreated() + * + * @deprecated Superseded by override-only user-config notification preferences + * (NotificationPreferenceService). Rows are migrated by the + * MigrateNotificationSubscriptionsToUserConfig repair step. */ class NotificationSubscription extends Entity { diff --git a/lib/Db/NotificationSubscriptionMapper.php b/lib/Db/NotificationSubscriptionMapper.php index c04125f94a..461dcc3662 100644 --- a/lib/Db/NotificationSubscriptionMapper.php +++ b/lib/Db/NotificationSubscriptionMapper.php @@ -27,6 +27,11 @@ * @spec openspec/changes/notificatie-engine/tasks.md "Users MUST be able to manage their notification preferences" * * @template-extends QBMapper + * + * @deprecated Superseded by override-only user-config notification preferences + * (NotificationPreferenceService). Rows are migrated by the + * MigrateNotificationSubscriptionsToUserConfig repair step and the + * table is scheduled for removal. */ declare(strict_types=1); diff --git a/lib/Listener/AnnotationNotificationListener.php b/lib/Listener/AnnotationNotificationListener.php index bb734ede2d..74fe6f5a99 100644 --- a/lib/Listener/AnnotationNotificationListener.php +++ b/lib/Listener/AnnotationNotificationListener.php @@ -89,7 +89,19 @@ public function handle(Event $event): void $newObject = $event->getNewObject(); $oldObject = $event->getOldObject(); - $this->dispatcher->dispatch(object: $newObject, trigger: 'updated'); + // Forward old/new data on the plain `updated` dispatch too (when an + // old object is available) so rules declaring a field-change + // `condition` can compare without re-reading versioned history. + // Condition-less `updated` rules ignore this context. + $updatedContext = []; + if ($oldObject !== null) { + $updatedContext = [ + '_newData' => $newObject->getObject() ?? [], + '_oldData' => $oldObject->getObject() ?? [], + ]; + } + + $this->dispatcher->dispatch(object: $newObject, trigger: 'updated', context: $updatedContext); // Also evaluate calculatedChange rules when both old and new // objects are available. Pass the previous and new calculated diff --git a/lib/Notification/AnnotationNotifier.php b/lib/Notification/AnnotationNotifier.php index ef4217f84e..9131853e31 100644 --- a/lib/Notification/AnnotationNotifier.php +++ b/lib/Notification/AnnotationNotifier.php @@ -3,9 +3,21 @@ /** * OpenRegister AnnotationNotifier * - * Renders annotation-driven notifications. The dispatcher stores the - * already-interpolated subject under the `_text` parameter; this notifier - * surfaces it as the notification's parsed subject. + * Renders annotation-driven, object-lifecycle notifications fired by + * AnnotationNotificationDispatcher. The dispatcher emits a canonical + * subject (object_created / object_updated / object_transitioned), the + * routing parameters for the object-detail action link (registerId, + * schemaId, objectUuid, objectTitle), and — when the schema declared a + * custom per-locale `subject` — the already-interpolated text under the + * `_text` parameter. + * + * This notifier renders the recipient-localised subject (the schema's + * custom `_text` wins; otherwise a canonical localised string from the + * openregister l10n files), sets the OpenRegister icon, and adds a primary + * "View" action deep-linking to the object. Subjects it does not own (no + * `_text` and not a canonical object subject — e.g. configuration_update_available, + * which lib/Notification/Notifier.php renders) raise UnknownNotificationException + * so the manager passes the notification on to the next notifier untouched. * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -26,6 +38,8 @@ namespace OCA\OpenRegister\Notification; +use OCP\IURLGenerator; +use OCP\L10N\IFactory; use OCP\Notification\INotification; use OCP\Notification\INotifier; use OCP\Notification\UnknownNotificationException; @@ -33,12 +47,28 @@ class AnnotationNotifier implements INotifier { /** - * No-op constructor, kept explicit so DI can resolve the notifier. + * Canonical object-lifecycle subjects mapped to their English source + * string (Dutch comes from l10n/nl.json via IFactory). Used to render a + * localised subject when the schema declared no custom `subject`. * - * @return void + * @var array */ - public function __construct() - { + private const SUBJECT_TEMPLATES = [ + 'object_created' => 'Object "%1$s" created in register "%2$s"', + 'object_updated' => 'Object "%1$s" updated in register "%2$s"', + 'object_transitioned' => 'Object "%1$s" assigned to you in register "%2$s"', + ]; + + /** + * Constructor. + * + * @param IFactory $factory L10N factory for localised subjects. + * @param IURLGenerator $urlGenerator URL generator for the icon and action link. + */ + public function __construct( + private readonly IFactory $factory, + private readonly IURLGenerator $urlGenerator + ) { }//end __construct() /** @@ -62,16 +92,16 @@ public function getName(): string }//end getName() /** - * Render the notification subject for the given language. + * Render the notification subject and action for the given language. * * @param INotification $notification Notification to prepare. * @param string $languageCode Active language code. * * @return INotification Prepared notification. * - * @throws UnknownNotificationException When the notification does not belong to OpenRegister. - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws UnknownNotificationException When the notification is not an + * annotation/object notification this + * notifier owns. */ public function prepare(INotification $notification, string $languageCode): INotification { @@ -79,15 +109,52 @@ public function prepare(INotification $notification, string $languageCode): INot throw new UnknownNotificationException(); } - $params = $notification->getSubjectParameters(); - $text = ($params['_text'] ?? null); - $parsedSubject = $notification->getSubject(); + $subject = $notification->getSubject(); + $params = $notification->getSubjectParameters(); + $text = ($params['_text'] ?? null); + $hasText = (is_string($text) === true && $text !== ''); + $isObject = array_key_exists($subject, self::SUBJECT_TEMPLATES); - if (is_string($text) === true && $text !== '') { - $parsedSubject = $text; + // Subjects this notifier does not own (e.g. configuration_update_available, + // rendered by Notifier) are passed on untouched. + if ($isObject === false && $hasText === false) { + throw new UnknownNotificationException(); + } + + $l = $this->factory->get('openregister', $languageCode); + + // The schema's custom per-locale subject (already interpolated by the + // dispatcher for this recipient) wins; otherwise render the canonical + // localised string with the object title + register name substituted. + if ($hasText === true) { + $notification->setParsedSubject($text); + } else { + $objectTitle = (string) ($params['objectTitle'] ?? $l->t('object')); + $registerName = (string) ($params['registerName'] ?? ($params['registerId'] ?? '')); + $notification->setParsedSubject( + $l->t(self::SUBJECT_TEMPLATES[$subject], [$objectTitle, $registerName]) + ); } - $notification->setParsedSubject($parsedSubject); + $notification->setIcon( + $this->urlGenerator->imagePath(appName: 'openregister', file: 'app.svg') + ); + + // Deep-link to the object detail view when routing params are present. + $registerId = ($params['registerId'] ?? null); + $schemaId = ($params['schemaId'] ?? null); + $objectUuid = ($params['objectUuid'] ?? null); + if ($registerId !== null && $schemaId !== null && $objectUuid !== null && (string) $objectUuid !== '') { + $action = $notification->createAction(); + $action->setLabel($l->t('View')) + ->setPrimary(true) + ->setLink( + $this->urlGenerator->linkToRouteAbsolute('openregister.dashboard.page') + .sprintf('#/registers/%s/schemas/%s/objects/%s', $registerId, $schemaId, $objectUuid), + 'GET' + ); + $notification->addAction($action); + } return $notification; }//end prepare() diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 137ddc048a..61692080e9 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -101,7 +101,9 @@ public function prepare(INotification $notification, string $languageCode): INot return $this->prepareConfigurationUpdate(notification: $notification, l: $l); default: - // Unknown subject. + // Unknown subject. Object-lifecycle subjects + // (object_created / object_updated / object_transitioned) + // are rendered by AnnotationNotifier, not here. throw new InvalidArgumentException('Unknown subject'); }//end switch }//end prepare() @@ -125,13 +127,13 @@ private function prepareConfigurationUpdate(INotification $notification, $l): IN $newVersion = $parameters['newVersion'] ?? 'unknown'; $notification->setParsedSubject( - $l->t(text: 'Configuration update available: %s', args: [$configurationTitle]) + $l->t('Configuration update available: %s', [$configurationTitle]) ); $notification->setParsedMessage( $l->t( - text: 'A new version (%s) of configuration "%s" is available. Current version: %s', - args: [$newVersion, $configurationTitle, $currentVersion] + 'A new version (%s) of configuration "%s" is available. Current version: %s', + [$newVersion, $configurationTitle, $currentVersion] ) ); @@ -142,7 +144,7 @@ private function prepareConfigurationUpdate(INotification $notification, $l): IN // Add action to view the configuration. if (($parameters['configurationId'] ?? null) !== null) { $action = $notification->createAction(); - $action->setLabel($l->t(text: 'View')) + $action->setLabel($l->t('View')) ->setPrimary(true) ->setLink( link: $this->urlGenerator->linkToRouteAbsolute( diff --git a/lib/Repair/MigrateNotificationSubscriptionsToUserConfig.php b/lib/Repair/MigrateNotificationSubscriptionsToUserConfig.php new file mode 100644 index 0000000000..99cf604fb1 --- /dev/null +++ b/lib/Repair/MigrateNotificationSubscriptionsToUserConfig.php @@ -0,0 +1,226 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + * + * @spec openspec/changes/notification-schema-rules-and-userconfig-prefs/tasks.md#task-5 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Repair step: migrate legacy notification subscriptions to user-config overrides. + */ +class MigrateNotificationSubscriptionsToUserConfig implements IRepairStep +{ + /** + * Legacy subscription table (without the oc_ prefix). + */ + private const TABLE = 'openregister_notification_subscriptions'; + + /** + * Constructor. + * + * @param ContainerInterface $container DI container — used to lazily resolve + * SchemaMapper / NotificationPreferenceService / + * IDBConnection so the step never blocks app boot. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger + ) { + }//end __construct() + + /** + * Human-readable step name surfaced in occ + admin UI. + * + * @return string + */ + public function getName(): string + { + return 'Migrate deprecated notification subscriptions to user-config overrides'; + }//end getName() + + /** + * Run the migration. + * + * @param IOutput $output Migration output handle. + * + * @return void + */ + public function run(IOutput $output): void + { + $output->info('[OpenRegister] Migrating legacy notification subscriptions to user-config...'); + + $rows = $this->loadSubscriptionRows(); + if ($rows === null) { + $output->info('[OpenRegister] Subscription table unavailable — migration skipped (normal on first install).'); + return; + } + + if ($rows === []) { + $output->info('[OpenRegister] No notification subscriptions to migrate.'); + return; + } + + $prefs = $this->resolvePreferenceService(); + $schemaMapper = $this->resolveSchemaMapper(); + if ($prefs === null || $schemaMapper === null) { + $output->warning('[OpenRegister] Preference service / schema mapper unavailable — migration skipped.'); + return; + } + + $migrated = 0; + foreach ($rows as $row) { + $userId = (string) ($row['user_id'] ?? ''); + $schemaId = ($row['schema_id'] ?? null); + if ($userId === '' || $schemaId === null) { + // Register-wide (schema_id NULL) subscriptions don't map to a + // single schema's keys; leave them for manual review. + continue; + } + + try { + $schema = $schemaMapper->find((int) $schemaId); + } catch (\Throwable $e) { + continue; + } + + if (($schema instanceof Schema) === false) { + continue; + } + + $config = ($schema->getConfiguration() ?? []); + $notifications = ($config['x-openregister-notifications'] ?? null); + if (is_array($notifications) === false) { + continue; + } + + $slug = (string) ($schema->getSlug() ?? $schema->getId()); + foreach (array_keys($notifications) as $key) { + $prefs->setOverride( + userId: $userId, + schemaSlug: $slug, + notificationKey: (string) $key, + override: ['enabled' => true] + ); + $migrated++; + } + }//end foreach + + $output->info(sprintf('[OpenRegister] Migrated %d notification subscription override(s).', $migrated)); + }//end run() + + /** + * Read the legacy subscription rows directly. Returns null when the + * table cannot be queried (e.g. fresh install before schema creation). + * + * @return array>|null + */ + private function loadSubscriptionRows(): ?array + { + try { + $db = $this->container->get(IDBConnection::class); + } catch (\Throwable $e) { + return null; + } + + try { + $qb = $db->getQueryBuilder(); + $qb->select('user_id', 'schema_id')->from(self::TABLE); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + if (is_array($rows) === false) { + return []; + } + + return $rows; + } catch (\Throwable $e) { + $this->logger->debug( + '[OpenRegister] MigrateNotificationSubscriptions could not read table — skipping', + ['exception' => $e] + ); + return null; + } + }//end loadSubscriptionRows() + + /** + * Lazily resolve the preference service. + * + * @return NotificationPreferenceService|null + */ + private function resolvePreferenceService(): ?NotificationPreferenceService + { + try { + $service = $this->container->get(NotificationPreferenceService::class); + if ($service instanceof NotificationPreferenceService) { + return $service; + } + + return null; + } catch (\Throwable $e) { + return null; + } + }//end resolvePreferenceService() + + /** + * Lazily resolve the schema mapper. + * + * @return mixed The SchemaMapper, or null when unresolvable. + */ + private function resolveSchemaMapper(): mixed + { + try { + $mapper = $this->container->get('OCA\\OpenRegister\\Db\\SchemaMapper'); + if (method_exists($mapper, 'find') === true) { + return $mapper; + } + + return null; + } catch (\Throwable $e) { + return null; + } + }//end resolveSchemaMapper() +}//end class diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index 356c4264ee..741ec72d65 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -71,8 +71,9 @@ class AnnotationNotificationDispatcher * @param IConfig|null $config Optional config service for runtime tunables. * @param NotificationHistoryMapper|null $historyMapper Optional history mapper for delivery audit rows. * @param NotificationCoalescer|null $coalescer Optional coalescer for burst suppression. - * @param \OCA\OpenRegister\Db\NotificationSubscriptionMapper|null $subscriptionMapper Optional subscription mapper for opt-in filtering. + * @param \OCA\OpenRegister\Db\NotificationSubscriptionMapper|null $subscriptionMapper Optional subscription mapper (DEPRECATED). * @param NotificationDispatchLogMapper|null $dispatchLogMapper Optional dispatch-log mapper for idempotency-key dedup. + * @param NotificationPreferenceService|null $preferenceService Override-only preference resolver (delivery gate). * * @SuppressWarnings(PHPMD.ExcessiveParameterList) DI-injected dependencies. */ @@ -91,7 +92,8 @@ public function __construct( private readonly ?NotificationHistoryMapper $historyMapper=null, private readonly ?NotificationCoalescer $coalescer=null, private readonly ?\OCA\OpenRegister\Db\NotificationSubscriptionMapper $subscriptionMapper=null, - private readonly ?NotificationDispatchLogMapper $dispatchLogMapper=null + private readonly ?NotificationDispatchLogMapper $dispatchLogMapper=null, + private readonly ?NotificationPreferenceService $preferenceService=null ) { }//end __construct() @@ -125,6 +127,15 @@ public function dispatch(ObjectEntity $object, string $trigger, array $context=[ $data = $object->getObject() ?? []; + // Canonical INotification subject derived from the trigger. The + // Notifier renders these (object_created / object_updated / + // object_transitioned) with localised text + an object-detail + // action link — decoupling rendering from how the schema author + // happened to name the rule, so EVERY schema-declared in-app + // notification renders rather than throwing "Unknown subject". + $subjectKey = $this->canonicalSubject(trigger: $trigger); + $schemaSlug = (string) ($schema->getSlug() ?? $schema->getId()); + foreach ($notifications as $name => $spec) { if (is_array($spec) === false) { continue; @@ -227,9 +238,17 @@ public function dispatch(ObjectEntity $object, string $trigger, array $context=[ ); $channels = (array) ($spec['channels'] ?? ['nc-notification']); - $rateLimit = is_array($spec['rateLimit'] ?? null) === true ? $spec['rateLimit'] : null; - $coalesce = is_array($spec['coalesce'] ?? null) === true ? $spec['coalesce'] : null; - $ruleId = (string) $name; + $rateLimit = null; + if (is_array($spec['rateLimit'] ?? null) === true) { + $rateLimit = $spec['rateLimit']; + } + + $coalesce = null; + if (is_array($spec['coalesce'] ?? null) === true) { + $coalesce = $spec['coalesce']; + } + + $ruleId = (string) $name; // Webhook is fired once per dispatch, not once per recipient, // and includes the recipient list in the payload. @@ -316,13 +335,46 @@ public function dispatch(ObjectEntity $object, string $trigger, array $context=[ continue; } - if (in_array('nc-notification', $channels, true) === true) { + // Per-recipient preference gate (override-only). The schema + // declares the default on/off + channels; a stored user + // override flips it. Absence of an override falls through to + // the schema default (zero-migration). Webhook/talk are + // broadcast (fired once above) and intentionally unaffected. + $effectiveChannels = $channels; + if ($this->preferenceService !== null) { + $pref = $this->preferenceService->resolveEffective( + schemaDefault: $spec, + userId: $uid, + schemaSlug: $schemaSlug, + notificationKey: (string) $name + ); + if ($pref['enabled'] === false) { + $this->recordHistoryAcrossChannels( + ruleId: $ruleId, + recipient: $uid, + channels: $channels, + broadcastChannels: ['webhook', 'talk'], + status: 'preference-off', + object: $object, + subject: $recipientSubject, + locale: $recipientLocale + ); + continue; + } + + if ($pref['channels'] !== null) { + $effectiveChannels = array_values(array_intersect($channels, $pref['channels'])); + } + }//end if + + if (in_array('nc-notification', $effectiveChannels, true) === true) { $this->emitNotification( uid: $uid, - objectId: (string) ($object->getUuid() ?? ''), + object: $object, + subjectKey: $subjectKey, name: (string) $name, subject: $recipientSubject, - parameters: $context + context: $context ); $this->recordHistory( ruleId: $ruleId, @@ -335,7 +387,7 @@ public function dispatch(ObjectEntity $object, string $trigger, array $context=[ ); } - if (in_array('email', $channels, true) === true) { + if (in_array('email', $effectiveChannels, true) === true) { $this->emitEmail( uid: $uid, subject: $recipientSubject, @@ -352,7 +404,7 @@ public function dispatch(ObjectEntity $object, string $trigger, array $context=[ ); } - if (in_array('activity', $channels, true) === true) { + if (in_array('activity', $effectiveChannels, true) === true) { $this->emitActivity( uid: $uid, objectId: (string) ($object->getUuid() ?? ''), @@ -659,15 +711,30 @@ private function recordHistory( return; } + $historySchemaId = null; + if ($object->getSchema() !== null && $object->getSchema() !== '') { + $historySchemaId = (string) $object->getSchema(); + } + + $historyRegisterId = null; + if ($object->getRegister() !== null && $object->getRegister() !== '') { + $historyRegisterId = (string) $object->getRegister(); + } + + $historyObjectUuid = null; + if ($object->getUuid() !== null && $object->getUuid() !== '') { + $historyObjectUuid = (string) $object->getUuid(); + } + try { $this->historyMapper->record( ruleId: $ruleId, channel: $channel, recipient: $recipient, status: $status, - schemaId: ($object->getSchema() !== null && $object->getSchema() !== '') ? (string) $object->getSchema() : null, - registerId: ($object->getRegister() !== null && $object->getRegister() !== '') ? (string) $object->getRegister() : null, - objectUuid: ($object->getUuid() !== null && $object->getUuid() !== '') ? (string) $object->getUuid() : null, + schemaId: $historySchemaId, + registerId: $historyRegisterId, + objectUuid: $historyObjectUuid, subject: $subject, errorMessage: null, locale: $locale @@ -894,7 +961,10 @@ private function emitWebhook( } $method = strtoupper((string) ($hook['method'] ?? 'POST')); - $headers = is_array($hook['headers'] ?? null) === true ? $hook['headers'] : []; + $headers = []; + if (is_array($hook['headers'] ?? null) === true) { + $headers = $hook['headers']; + } $payload = [ 'notification' => $notificationName, @@ -963,6 +1033,26 @@ private function matches(array $triggerSpec, string $trigger, array $context): b } } + // Optional non-numeric field-change `condition` for `updated` triggers. + // Engages ONLY when the trigger declares a `condition`; condition-less + // `updated` rules match on type alone (back-compat). Reads the old/new + // object data the listener forwards; fail-closed when either is absent + // (mirrors the `calculatedChange` guard below). + if ($trigger === 'updated' && isset($triggerSpec['condition']) === true) { + $condition = $triggerSpec['condition']; + if (is_array($condition) === false) { + return false; + } + + $newData = ($context['_newData'] ?? null); + $oldData = ($context['_oldData'] ?? null); + if (is_array($newData) === false || is_array($oldData) === false) { + return false; + } + + return $this->fieldChangeConditionMatches(condition: $condition, oldData: $oldData, newData: $newData); + } + // `calculatedChange` boundary-crossing check. // `field` names the calculated property to monitor. // `condition` operators the NEW value must satisfy. @@ -1045,6 +1135,65 @@ private function numericConditionMatches(mixed $value, array $operators): bool return true; }//end numericConditionMatches() + /** + * Evaluate a non-numeric field-change condition for an `updated` trigger. + * + * Compares one field's value between the old and new object data: + * - `changed` — matches when old != new. + * - `equals` (+ `value`) — matches when new == value; when optional + * `from` is present, also requires old == from + * (i.e. a specific `from` -> `value` transition). + * + * Comparison is string-normalised (scalars cast to string, non-scalars to + * the empty string), consistent with the `eq`/`ne` handling in + * `numericConditionMatches()`. An empty/missing `field` returns false. + * + * @param array $condition The `condition` sub-document (`field`, `operator`, `value`, `from`). + * @param array $oldData The object's data before the update. + * @param array $newData The object's data after the update. + * + * @return bool True when the declared change occurred. + */ + private function fieldChangeConditionMatches(array $condition, array $oldData, array $newData): bool + { + $field = (string) ($condition['field'] ?? ''); + if ($field === '') { + return false; + } + + $operator = (string) ($condition['operator'] ?? 'changed'); + $oldValue = ($oldData[$field] ?? null); + $newValue = ($newData[$field] ?? null); + + $oldStr = ''; + if (is_scalar($oldValue) === true) { + $oldStr = (string) $oldValue; + } + + $newStr = ''; + if (is_scalar($newValue) === true) { + $newStr = (string) $newValue; + } + + if ($operator === 'changed') { + return $oldStr !== $newStr; + } + + if ($operator === 'equals') { + if ($newStr !== (string) ($condition['value'] ?? '')) { + return false; + } + + if (isset($condition['from']) === true) { + return $oldStr === (string) $condition['from']; + } + + return true; + } + + return false; + }//end fieldChangeConditionMatches() + /** * Resolve a `recipients` block to a flat list of UIDs. * @@ -1432,7 +1581,10 @@ private function resolveLocalizedSubject( if (is_array($template) === true) { $declared = isset($template['defaultLocale']) === true && is_string($template['defaultLocale']) === true; - $defaultLocale = $declared === true ? $template['defaultLocale'] : 'nl'; + $defaultLocale = 'nl'; + if ($declared === true) { + $defaultLocale = $template['defaultLocale']; + } // Recipient locale wins when declared. if ($locale !== null && isset($template[$locale]) === true && is_string($template[$locale]) === true) { @@ -1561,27 +1713,78 @@ static function (array $matches) use ($data, $context): string { ) ?? $template; }//end interpolate() + /** + * Map a trigger to the canonical INotification subject the Notifier + * renders. Decouples the displayed subject from the schema author's + * rule name so every schema-declared in-app notification renders. + * + * @param string $trigger 'created' | 'updated' | 'transition' | 'calculatedChange'. + * + * @return string The canonical subject identifier. + */ + private function canonicalSubject(string $trigger): string + { + return match ($trigger) { + 'created' => 'object_created', + 'transition' => 'object_transitioned', + default => 'object_updated', + }; + }//end canonicalSubject() + /** * Persist + dispatch a single in-app Nextcloud notification row. * + * The INotification carries the canonical `$subjectKey` (which the + * Notifier switches on to render localised text + an object-detail + * action link), the routing parameters the action link needs + * (`objectTitle`, `registerId`, `schemaId`, `objectUuid`), the rule's + * own name under `notificationType`, and the pre-rendered subject text + * under `_text` (so a schema's custom per-locale subject still wins). + * + * Push delivery needs no extra code: `notify_push` auto-intercepts this + * same `IManager::notify()` call and relays it to connected devices. + * * @param string $uid Recipient user UID. - * @param string $objectId The owning object's UUID (or rule name fallback). - * @param string $name Annotation name (notification type identifier). + * @param ObjectEntity $object The object the event happened on. + * @param string $subjectKey Canonical subject identifier (object_created/_updated/_transitioned). + * @param string $name Annotation rule name (notification type identifier). * @param string $subject Pre-interpolated subject text. - * @param array $parameters Extra notification parameters. + * @param array $context Trigger context (action, from, to). * * @return void */ - private function emitNotification(string $uid, string $objectId, string $name, string $subject, array $parameters): void - { + private function emitNotification( + string $uid, + ObjectEntity $object, + string $subjectKey, + string $name, + string $subject, + array $context + ): void { + $objectUuid = (string) ($object->getUuid() ?? ''); + $linkParams = [ + 'objectTitle' => (string) ($object->getName() ?? $objectUuid), + 'registerId' => $object->getRegister(), + 'schemaId' => $object->getSchema(), + 'objectUuid' => $objectUuid, + ]; + + $objectRef = $name; + if ($objectUuid !== '') { + $objectRef = $objectUuid; + } + try { $notification = $this->notificationManager->createNotification(); $notification ->setApp('openregister') ->setUser($uid) ->setDateTime(new DateTime()) - ->setObject('object', $objectId !== '' ? $objectId : $name) - ->setSubject($name, array_merge($parameters, ['_text' => $subject])); + ->setObject('object', $objectRef) + ->setSubject( + $subjectKey, + array_merge($context, $linkParams, ['_text' => $subject, 'notificationType' => $name]) + ); $this->notificationManager->notify($notification); } catch (\Throwable $e) { $this->logger->warning( @@ -1642,6 +1845,11 @@ private function emitEmail(string $uid, string $subject, string $body): void */ private function emitActivity(string $uid, string $objectId, string $name, string $subject): void { + $objectRef = $name; + if ($objectId !== '') { + $objectRef = $objectId; + } + try { $event = $this->activityManager->generateEvent(); $event @@ -1649,7 +1857,7 @@ private function emitActivity(string $uid, string $objectId, string $name, strin ->setType('openregister_objects') ->setAffectedUser($uid) ->setSubject($name, ['_text' => $subject]) - ->setObject('object', 0, $objectId !== '' ? $objectId : $name) + ->setObject('object', 0, $objectRef) ->setTimestamp(time()); $this->activityManager->publish($event); } catch (\Throwable $e) { @@ -1691,7 +1899,11 @@ private function getAnnotation(Schema $schema): ?array { $config = ($schema->getConfiguration() ?? []); $value = ($config['x-openregister-notifications'] ?? null); - return is_array($value) === true ? $value : null; + if (is_array($value) === true) { + return $value; + } + + return null; }//end getAnnotation() /** diff --git a/lib/Service/Notification/NotificationPreferenceService.php b/lib/Service/Notification/NotificationPreferenceService.php new file mode 100644 index 0000000000..49f62ad21d --- /dev/null +++ b/lib/Service/Notification/NotificationPreferenceService.php @@ -0,0 +1,258 @@ +/ + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @category Service + * @package OCA\OpenRegister\Service\Notification + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Notification; + +use OCA\OpenRegister\Db\SchemaMapper; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + +/** + * Resolves and stores override-only notification preferences. + */ +class NotificationPreferenceService +{ + /** + * App id used for the user-config namespace. + */ + private const APP_NAME = 'openregister'; + + /** + * Prefix for every per-(schema, notification) override config key. + */ + private const KEY_PREFIX = 'notification_pref/'; + + /** + * Constructor. + * + * @param IConfig $config Nextcloud config for per-user values. + * @param SchemaMapper $schemaMapper Mapper used to enumerate accessible schemas. + * @param LoggerInterface $logger Logger for diagnostics. + */ + public function __construct( + private readonly IConfig $config, + private readonly SchemaMapper $schemaMapper, + private readonly LoggerInterface $logger + ) { + }//end __construct() + + /** + * Build the user-config key for a `(schemaSlug, notificationKey)` pair. + * + * @param string $schemaSlug The owning schema's slug. + * @param string $notificationKey The notification annotation key. + * + * @return string The namespaced config key. + */ + public function configKey(string $schemaSlug, string $notificationKey): string + { + return self::KEY_PREFIX.$schemaSlug.'/'.$notificationKey; + }//end configKey() + + /** + * Read a user's stored override for a `(schemaSlug, notificationKey)` + * pair. Returns null when no override is stored (fall through to the + * schema default) — never throws for an unknown key. + * + * @param string $userId The user UID. + * @param string $schemaSlug The owning schema's slug. + * @param string $notificationKey The notification annotation key. + * + * @return array|null The decoded override, or null when none/invalid. + */ + public function getOverride(string $userId, string $schemaSlug, string $notificationKey): ?array + { + $raw = $this->config->getUserValue( + $userId, + self::APP_NAME, + $this->configKey(schemaSlug: $schemaSlug, notificationKey: $notificationKey), + '' + ); + + if ($raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + if (is_array($decoded) === false) { + return null; + } + + return $decoded; + }//end getOverride() + + /** + * Write or clear a user's override for one `(schemaSlug, notificationKey)` + * pair. Passing null clears the override so the schema default applies + * again (zero-migration fall-through). + * + * @param string $userId The user UID. + * @param string $schemaSlug The owning schema's slug. + * @param string $notificationKey The notification annotation key. + * @param array|null $override Override body (`enabled`, optional `channels`) or null to clear. + * + * @return void + */ + public function setOverride(string $userId, string $schemaSlug, string $notificationKey, ?array $override): void + { + $key = $this->configKey(schemaSlug: $schemaSlug, notificationKey: $notificationKey); + + if ($override === null) { + $this->config->deleteUserValue($userId, self::APP_NAME, $key); + return; + } + + $clean = ['enabled' => (bool) ($override['enabled'] ?? true)]; + if (isset($override['channels']) === true && is_array($override['channels']) === true) { + $clean['channels'] = array_values( + array_filter($override['channels'], static fn($c): bool => is_string($c) === true && $c !== '') + ); + } + + $this->config->setUserValue($userId, self::APP_NAME, $key, json_encode($clean)); + }//end setOverride() + + /** + * Resolve the EFFECTIVE preference for a `(schemaSlug, notificationKey)` + * pair as `schema-default ⊕ user-override`. Unknown keys with no stored + * override resolve to the schema default without error. + * + * @param array $schemaDefault The notification spec block from the schema (provides `enabled`/`channels`). + * @param string $userId The user UID. + * @param string $schemaSlug The owning schema's slug. + * @param string $notificationKey The notification annotation key. + * + * @return array{enabled: bool, channels: array|null, source: string} + */ + public function resolveEffective( + array $schemaDefault, + string $userId, + string $schemaSlug, + string $notificationKey + ): array { + $defaultEnabled = (bool) ($schemaDefault['enabled'] ?? true); + $defaultChannels = null; + if (isset($schemaDefault['channels']) === true && is_array($schemaDefault['channels']) === true) { + $defaultChannels = array_values($schemaDefault['channels']); + } + + $override = $this->getOverride(userId: $userId, schemaSlug: $schemaSlug, notificationKey: $notificationKey); + if ($override === null) { + return [ + 'enabled' => $defaultEnabled, + 'channels' => $defaultChannels, + 'source' => 'schema-default', + ]; + } + + $enabled = (bool) ($override['enabled'] ?? $defaultEnabled); + $channels = $defaultChannels; + if (isset($override['channels']) === true && is_array($override['channels']) === true) { + // Channel narrowing: the override may only RESTRICT to a subset + // of the schema-declared channels, never widen beyond them. + $narrowed = array_values(array_intersect($override['channels'], ($defaultChannels ?? $override['channels']))); + $channels = $narrowed; + } + + return [ + 'enabled' => $enabled, + 'channels' => $channels, + 'source' => 'user-override', + ]; + }//end resolveEffective() + + /** + * Enumerate the EFFECTIVE notifications for a user: every notification + * declared by the user's accessible schemas, merged with that user's + * overrides, each tagged with its `source`. + * + * RBAC + multitenancy on `SchemaMapper::findAll()` already scope the + * schemas to those the user may read, so this never leaks notifications + * for inaccessible schemas. + * + * @param string $userId The user UID. + * + * @return array> One entry per (schema, notification) pair. + */ + public function getEffectiveForUser(string $userId): array + { + $entries = []; + + try { + $schemas = $this->schemaMapper->findAll(); + } catch (\Throwable $e) { + $this->logger->warning( + '[NotificationPreferenceService] schema enumeration failed: '.$e->getMessage() + ); + return []; + } + + foreach ($schemas as $schema) { + $config = ($schema->getConfiguration() ?? []); + $notifications = ($config['x-openregister-notifications'] ?? null); + if (is_array($notifications) === false) { + continue; + } + + $schemaSlug = (string) ($schema->getSlug() ?? $schema->getId()); + $schemaTitle = (string) ($schema->getTitle() ?? $schemaSlug); + + foreach ($notifications as $key => $spec) { + if (is_array($spec) === false) { + continue; + } + + $effective = $this->resolveEffective( + schemaDefault: $spec, + userId: $userId, + schemaSlug: $schemaSlug, + notificationKey: (string) $key + ); + + $entries[] = [ + 'schema' => $schemaSlug, + 'schemaTitle' => $schemaTitle, + 'notification' => (string) $key, + 'enabled' => $effective['enabled'], + 'channels' => $effective['channels'], + 'source' => $effective['source'], + ]; + }//end foreach + }//end foreach + + return $entries; + }//end getEffectiveForUser() +}//end class diff --git a/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/.openspec.yaml b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/.openspec.yaml new file mode 100644 index 0000000000..9e883bff04 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/design.md b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/design.md new file mode 100644 index 0000000000..5fa564f1b1 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/design.md @@ -0,0 +1,119 @@ +## Context + +OpenRegister's `notificatie-engine` spec already establishes that schema-annotated notifications drive delivery, and `x-openregister-notifications` is already an accepted key in `Schema::ANNOTATION_VOCABULARY`. But the dispatch path is only half-wired: + +- `lib/Service/Notification/NotificationsAnnotationInstaller.php` reads the annotation on schema-save and materialises ONLY the `webhook` channel (it upserts persistent `Webhook` entities so the existing `WebhookService` delivers them). There is no in-app/push dispatch from the annotation. +- `lib/Notification/Notifier.php::prepare()` has a single-case switch that only handles `configuration_update_available`. Any schema-declared in-app notification therefore renders nothing in the bell. +- An existing `NotificationSubscription` entity + `NotificationSubscriptionMapper` + `NotificationSubscriptionsController` implements a per-user subscribe/unsubscribe table — the kind of preference-persistence layer this change is explicitly removing. +- `NotificationService` + `Notifier` already use `OCP\Notification\IManager` / `INotifier` correctly. Push (browser-closed pop-ups) already works because `notify_push` auto-intercepts `IManager` notifications — no separate push code is needed. + +The user has two hard architectural constraints that this design must honour: notification **rules** live ONLY in the schema annotation (no rule table; evaluate from the always-loaded schema at dispatch time), and user **preferences** are override-only Nextcloud per-user app-config values (no preference/subscription table; unknown keys fall through to the schema default with zero migration). + +## Goals / Non-Goals + +**Goals:** +- Close the in-app/push dispatch gap: evaluate `x-openregister-notifications` rules directly from the loaded `Schema` at dispatch time and fan out `nc-notification` / `push` via `IManager`. +- Extend `Notifier::prepare()` to render `object_created`, `object_updated`, and an assignment/transition subject with nl+en i18n and an object-detail action link. +- Add an override-only, user-config-backed preferences read/write API (effective-GET, single-pair override-PUT) under app `openregister`. +- Make the dispatcher consult `schema-default ⊕ user-override` per recipient before delivering in-app/push. +- Preserve the zero-migration fall-through invariant: new schemas and new notifications work without any backfill. +- Deprecate the `NotificationSubscription` table + controller and migrate existing rows to user-config overrides. + +**Non-Goals:** +- The nextcloud-vue user-settings preferences pane (separate nc-vue change — consumes this API; replaces the `

User preferences will appear here.

` placeholder in `CnAppRoot.vue` ~line 217). +- Per-app `x-openregister-notifications` default declarations (separate config changes in pipelinq + procest). +- New notification channels beyond in-app/push/webhook, batching/digest, rate limiting, history/audit tables, threshold/deadline triggers — those remain in the broader `notificatie-engine` spec backlog and are untouched here. +- Any new OR schema or DB table (this is engine code). + +## Decisions + +### Decision 1: Evaluate rules from the loaded schema, not a rule table +The dispatcher (the `AnnotationNotificationDispatcher` seam named in the engine spec) reads `configuration['x-openregister-notifications']` off the `Schema` already loaded for the object at the moment a lifecycle event fires, and fans out the in-app/push channels. **Why over a rule table:** the schema is always loaded when anything happens to an object, so a rule table would be a redundant second copy that drifts and demands migration on every schema edit. ADR-031 makes the annotation the declarative source of truth. The existing webhook-materialisation path stays as-is because `Webhook` entities are legitimately persistent delivery infrastructure (HMAC, retry, dead-letter) — that is delivery state, not rule state. + +### Decision 2: Preferences are override-only Nextcloud user-config values +Store one entry per `(schema, notification-key)` override under app `openregister` via `IConfig::setUserValue`. Resolution is `effective = user-override ?? schema-default`. **Why over a table:** an override-only user-config model gives the zero-migration fall-through property for free — unknown keys simply have no stored value and resolve to the schema default. A `NotificationPreference` table would need a row (or a migration) per user per notification and would break the moment a schema adds a notification. **Alternative considered — one JSON blob per user vs one key per pair:** deferred (see Open Questions); provisional default is one user-config key per `(schema, notification-key)` pair, namespaced so a blob migration stays possible later. + +### Decision 3: Render object subjects in the existing `AnnotationNotifier` (the actual handler), keep the two notifiers mutually exclusive by subject +The object-lifecycle subjects (`object_created`, `object_updated`, `object_transitioned`) are rendered in `lib/Notification/AnnotationNotifier.php` — localised via `IFactory::get('openregister', )`, with a primary action to the object detail route and the OR icon. **Why not `Notifier`:** OpenRegister already registers TWO `INotifier`s — `Notifier` (via `appinfo/info.xml`, handling `configuration_update_available`) and `AnnotationNotifier` (via `registerNotifierService`, the catch-all the dispatcher's notifications actually reach). Nextcloud's `Manager::prepare()` runs EVERY registered notifier sequentially over the same notification, so putting object-subject rendering in `Notifier` as well would let both notifiers touch the same notification (duplicate parsed subjects / actions). Instead the two are made **mutually exclusive by subject**: `AnnotationNotifier` owns object subjects (and anything carrying a pre-rendered `_text`) and raises `UnknownNotificationException` for everything else; `Notifier` keeps only `configuration_update_available`. The schema author's custom per-locale `subject` (passed as `_text`) wins; otherwise the canonical localised string is rendered. This is order-independent and avoids the collision a single-notifier assumption would have caused. + +### Decision 4: Push needs no code, only a declared channel +The `push` channel is satisfied by `notify_push` auto-intercepting the same `IManager::notify()` call. So "support push" means: declare the channel in the annotation and route it through the same in-app delivery, gated by the same per-recipient merged preference. No push-specific service. + +### Decision 5: Deprecate-then-remove the NotificationSubscription layer +Mark `NotificationSubscriptionsController` `@deprecated`, add a one-shot repair/migration that translates any existing `NotificationSubscription` rows into equivalent user-config overrides, and schedule table/mapper/controller removal. **Why not hard-remove now:** existing deployments may have rows + external callers; a deprecation window avoids data loss and lets the migration prove out. (Recorded as a DEFERRED_QUESTION — human-judgment call.) + +## Declarative-vs-imperative decision (ADR-031) + +| Concern | Declarative or imperative? | Rationale | +|---|---|---| +| **Notification rules** (who, on which event, which channels, recipient resolvers) | **Declarative** — `x-openregister-notifications` on the schema | ADR-031 names `x-openregister-notifications` as the declarative replacement for app-local NotificationService code. This change makes the annotation the ONLY rule store and evaluates it at dispatch time from the always-loaded schema. No `NotificationRule` service/table. | +| **Per-app rule content** (pipelinq new-lead → sales group; procest case-assigned → assignedTo) | **Declarative** — schema register patches in those repos | Per ADR-031 these are config changes (`kind: config`) in pipelinq/procest, NOT code here. | +| **The dispatcher + `Notifier::prepare()` rendering** | **Imperative (engine code) — justified exception** | This is the OR engine that *interprets* the declarative annotation and bridges to `IManager`/`INotifier`. ADR-031 §"What apps SHOULD still write in PHP" covers exactly this: the schema engine itself is PHP that the declarative metadata runs on. Rendering localised NC notifications + action links is framework-integration glue, not per-app business logic. | +| **The user-config preference merge** | **Imperative (engine code) — justified exception** | Resolving `schema-default ⊕ user-override` against `IConfig` is engine plumbing, not declarable behaviour. It belongs in OR so every consuming app inherits it uniformly (ADR-022). | + +This change is `kind: code` (not `mixed`): the centre of mass is OR engine code (dispatcher, AnnotationNotifier, preferences API, deprecation migration). It declares no per-app schema content itself — that lands in the downstream pipelinq/procest config changes. + +## Seed Data section (ADR-001) + +**This change introduces NO new OpenRegister schemas and NO new DB tables**, so there is no `lib/Settings/{app}_register.json` seed-data addition and no seed-data implementation task. Instead, below are the canonical SHAPES this engine consumes — for documentation and test fixtures only. All values are SAFE placeholders (nil UUID `00000000-0000-0000-0000-000000000000`, `YOUR_*_HERE`); no realistic-looking secrets or UUIDs. + +**Example `x-openregister-notifications` rule shape (lives on a schema's `configuration`, declared by the consuming app):** +```jsonc +{ + "x-openregister-notifications": { + "object_created": { + "event": "object.created", + "enabled": true, // schema default + "channels": ["nc-notification", "push"], + "recipients": { "groups": ["YOUR_GROUP_HERE"] }, + "subject": { + "nl": "Object \"%s\" aangemaakt in register \"%s\"", + "en": "Object \"%s\" created in register \"%s\"" + } + }, + "object_assigned": { + "event": "object.updated", + "enabled": true, + "condition": { "field": "assignedTo", "operator": "changed" }, + "channels": ["nc-notification"], + "recipients": { "field": "assignedTo" }, + "subject": { + "nl": "Object \"%s\" aan je toegewezen", + "en": "Object \"%s\" assigned to you" + } + } + } +} +``` + +**Example user-config override key shape (app `openregister`, per-user, override-only):** +``` +app: openregister +userId: YOUR_USER_ID_HERE +configKey: notification_pref// +configValue (JSON): {"enabled": false} // on/off only + or: {"enabled": true, "channels": ["nc-notification"]} // optional channel narrowing +``` +- Example concrete key: `notification_pref/meldingen/object_created` → `{"enabled": false}`. +- Schema reference in the key uses the schema slug (stable, human-readable); the nil-UUID `00000000-0000-0000-0000-000000000000` stands in wherever a UUID-shaped placeholder is needed in fixtures. +- **Absence of a key = use the schema default.** This is the zero-migration fall-through: no row, no migration, no backfill needed for new schemas or new notification keys. + +## Risks / Trade-offs + +- **Per-recipient preference resolution adds reads on the dispatch hot path** → Resolve via `IConfig::getUserValue` (already cached by NC) and read each recipient's override at most once per dispatch; only consult for the in-app/push channels (webhook is unaffected). Profile if a bulk event fans out to many recipients. +- **Schema slug as the key component is sensitive to slug renames** → A schema slug rename would orphan existing overrides (they fall through to default — fail-safe, never fail-open). Acceptable; documented. Keying by immutable schema UUID is the alternative captured in Open Questions. +- **Deprecating `NotificationSubscriptionsController` may break external callers** → Deprecation window + a one-shot migration of existing rows to user-config; removal scheduled, not immediate. Recorded as a DEFERRED_QUESTION. +- **Channel-level override semantics are under-specified** → Whether overrides may select channels (in-app vs push vs both) or only on/off is a DEFERRED_QUESTION; provisional design supports an optional `channels` field that narrows, defaulting to on/off when absent. +- **`x-openregister-notifications` rule schema is broader than this change implements** → This change wires in-app/push dispatch + the preference gate; advanced rule features (digest, threshold, rate-limit) remain in the engine backlog and are explicitly out of scope, so a rule declaring them must degrade gracefully (ignore unimplemented fields) rather than error. + +## Migration Plan + +1. Land the dispatcher in-app/push path + `Notifier::prepare()` subjects behind the existing annotation (no schema declares them in OR itself; downstream apps opt in). +2. Add the user-config preferences service + controller (effective-GET, override-PUT). +3. Wire the per-recipient merged-preference gate into the dispatcher. +4. Add a one-shot repair/migration translating any existing `NotificationSubscription` rows → user-config overrides; mark the controller `@deprecated`. +5. **Rollback:** the change is additive on the dispatch side (schemas must opt in via annotation) — disabling the dispatcher in-app/push path reverts to today's behaviour. The deprecation migration is read-then-write into user-config and can be re-run idempotently; the original rows are left in place during the deprecation window. + +## Open Questions + +See the DEFERRED_QUESTIONS list reported with this change. In brief: (1) deprecate vs hard-remove the `NotificationSubscription` table + controller; (2) one JSON blob per user vs one user-config key per `(schema, notification-key)` pair; (3) whether overrides may change the channel (in-app/push/both) or only on/off; (4) key by schema slug vs immutable schema UUID. diff --git a/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/proposal.md b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/proposal.md new file mode 100644 index 0000000000..a3232ddac8 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/proposal.md @@ -0,0 +1,50 @@ +--- +kind: code +depends_on: [] +chain: + - notification-schema-rules-and-userconfig-prefs # this spec (OpenRegister engine) + # downstream, in separate repos (NOT Hydra-chained here, narrated below): + # nextcloud-vue — user-settings notification preferences pane + # pipelinq — schema x-openregister-notifications declarations (new lead/contact) + # procest — schema x-openregister-notifications declarations (case assigned) +--- + +# Notification Schema Rules and User-Config Preferences + +## Why + +The `notificatie-engine` spec already declares that schema-annotated notifications drive in-app delivery, and `x-openregister-notifications` is already an accepted annotation key — but the dispatch path is half-wired. `NotificationsAnnotationInstaller` only materialises the `webhook` channel, and `Notifier::prepare()` only renders `configuration_update_available`. So a schema that declares an `object.created` in-app notification today produces nothing in the bell. At the same time the engine spec proposed `NotificationRule`, `NotificationPreference`, and `NotificationSubscription` tables — persistence layers that contradict ADR-031's declarative-first principle (the schema is the single source of truth) and add migration burden every time an app adds a schema or a notification. + +This change corrects the engine to honour two hard architectural constraints: **notification rules live ONLY in the schema annotation** (evaluated directly from the always-loaded schema at dispatch time, no rule table), and **user preferences are override-only values in Nextcloud's per-user app config** (no preference/subscription table), so new schemas and new notifications keep working with zero migration. + +## What Changes + +- **Dispatch in-app/push directly from the schema annotation.** The dispatcher MUST read `configuration['x-openregister-notifications']` off the already-loaded `Schema` at the moment an object lifecycle event fires and fan out the `nc-notification` / `push` channels through `OCP\Notification\IManager`. No `NotificationRule` table; no rule-persistence layer. (The existing webhook-materialisation path in `NotificationsAnnotationInstaller` stays — webhooks are persistent `Webhook` entities by design; only the in-app/push gap is closed.) +- **Render object-lifecycle subjects in `Notifier::prepare()`.** Extend the current single-subject switch to handle `object_created`, `object_updated`, and an assignment/transition subject, with nl+en i18n via `IFactory` and a primary action link to the object detail view. Reuse the subject/param/route shape already specified in the `notificatie-engine` spec scenarios. +- **User preferences as override-only Nextcloud user-config.** Store per-user overrides via `IConfig::setUserValue` under app `openregister`. A stored value only flips the schema-declared default (on/off, and optionally channel) for one `(schema, notification-key)` pair. When NO override exists, the schema default applies — unknown keys fall through. **No `NotificationPreference` table.** +- **Effective-preferences read/write API.** Add OR endpoints to GET the *effective* notifications for a user (schema defaults merged with that user's overrides) and to PUT a single `(schema, notification-key)` override. This is the contract the nextcloud-vue settings pane consumes. +- **Dispatcher consults the merged preference before delivery.** Before delivering the in-app/push channel to a given recipient, the dispatcher MUST resolve `schema-default ⊕ user-override` and skip recipients whose effective preference is off. +- **Deprecate the `NotificationSubscription` table + controller.** The existing per-user subscribe/unsubscribe table contradicts the override-only model. **BREAKING** (internal API): mark `NotificationSubscriptionsController` deprecated, migrate any existing rows into user-config overrides, and schedule removal. (See DEFERRED_QUESTIONS — deprecate-vs-hard-remove is a human-judgment call; provisional decision is deprecate-then-remove.) + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `notificatie-engine`: The schema-annotation rule source becomes the ONLY rule store (drop the proposed `NotificationRule` table); user preferences become override-only Nextcloud user-config values (drop the proposed `NotificationPreference`/`NotificationSubscription` tables). New requirements: the zero-migration fall-through invariant; `Notifier::prepare()` rendering of object-lifecycle subjects with nl+en i18n + action link; the effective-preferences read/write API; the merged-preference delivery gate. + +## Impact + +- **Code (OpenRegister):** + - `lib/Notification/Notifier.php` — extend `prepare()` with `object_created` / `object_updated` / assignment subjects, nl+en i18n, object-detail action link. + - `lib/Service/Notification/NotificationsAnnotationInstaller.php` — adjacent dispatcher path: read in-app/push channels from the annotation at dispatch time (today it only materialises webhooks at save time). + - A dispatcher seam (`AnnotationNotificationDispatcher` per the engine spec) that evaluates schema rules + the merged user preference and calls `IManager::notify()`. + - A user-config-backed preferences service + controller (effective-GET, override-PUT) under app `openregister`. + - `lib/Db/NotificationSubscription.php`, `lib/Db/NotificationSubscriptionMapper.php`, `lib/Controller/NotificationSubscriptionsController.php` — deprecate; add a one-shot migration of existing rows to user-config. +- **No new OR schemas / no new DB tables** introduced by this change (it is engine code). Push works via `notify_push` auto-interception of `IManager` — no separate push code. +- **APIs:** new effective-preferences GET + override PUT endpoints (consumed by the nc-vue pane). `NotificationSubscriptions*` endpoints become deprecated. +- **Dependent / linked changes (separate repos — NOT tasks here):** + 1. This OpenRegister engine change (this spec) lands the dispatch + API. + 2. **nextcloud-vue** — replace the `

User preferences will appear here.

` placeholder in `src/components/CnAppRoot/CnAppRoot.vue` (~line 217) with a preferences pane that calls the new OR effective-GET / override-PUT API. + 3. **pipelinq / procest** — add `x-openregister-notifications` default declarations to their schemas (pipelinq: new lead/contact → notify sales group on `object.created`; procest: case assigned → notify `object.assignedTo` on assignee change). These are config changes in those repos. diff --git a/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/specs/notificatie-engine/spec.md b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/specs/notificatie-engine/spec.md new file mode 100644 index 0000000000..574377df9e --- /dev/null +++ b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/specs/notificatie-engine/spec.md @@ -0,0 +1,179 @@ +## ADDED Requirements + +### Requirement: Notification rules MUST be sourced ONLY from the schema annotation, evaluated at dispatch time +The system MUST treat `configuration['x-openregister-notifications']` on the `Schema` as the single, authoritative source of notification rules. Because the schema is ALWAYS loaded whenever anything happens to an object, the dispatcher MUST evaluate the notification rules directly from the already-loaded schema annotation at dispatch time. The system MUST NOT persist notification rules in any separate rule table, and MUST NOT introduce a `NotificationRule` entity or `oc_openregister_notification_rules` table. (ADR-031: `x-openregister-notifications` is the declarative replacement for app-local notification service code.) + +#### Scenario: Dispatcher reads rules from the loaded schema, not a rule table +- **WHEN** an object lifecycle event fires for an object whose schema declares an `x-openregister-notifications` rule on `object.created` +- **THEN** the dispatcher MUST evaluate that rule from the schema annotation already loaded for the object +- **AND** the system MUST NOT query any notification-rule table (none exists) + +#### Scenario: Editing the schema annotation changes dispatch behaviour immediately +- **WHEN** an administrator updates `x-openregister-notifications` on a schema to add a new `object.updated` rule and saves the schema +- **THEN** the next `object.updated` event on that schema MUST be evaluated against the new rule +- **AND** no rule-table row creation, migration, or rebuild step is required for the change to take effect + +#### Scenario: No rule persistence layer for in-app/push channels +- **WHEN** a schema declares an in-app (`nc-notification`) or `push` channel rule +- **THEN** the rule MUST NOT be materialised into any persistent rule/subscription row for those channels +- **AND** the only persistent entity the annotation may materialise remains the `Webhook` entity for `webhook`-channel rules (the existing `NotificationsAnnotationInstaller` behaviour, unchanged) + +### Requirement: User notification preferences MUST be override-only values stored in Nextcloud per-user app config +The system MUST store a user's notification preferences as per-user app-config values under the `openregister` app via `OCP\IConfig::setUserValue` (or `OCP\IUserConfig`). A stored user value MUST act ONLY as an override that flips the schema-declared default (on/off, and optionally channel) for a single `(schema, notification-key)` pair. The system MUST NOT introduce a `NotificationPreference` table or rely on a `NotificationSubscription` table for preference resolution. + +#### Scenario: Stored override flips the schema default off +- **GIVEN** schema `meldingen` declares notification key `object_created` with default `enabled: true` +- **AND** user `behandelaar-1` has stored an override for `(meldingen, object_created)` of `enabled: false` +- **WHEN** a melding object is created and `behandelaar-1` is a resolved recipient +- **THEN** the system MUST NOT deliver the in-app/push notification to `behandelaar-1` + +#### Scenario: No stored override means the schema default applies +- **GIVEN** schema `meldingen` declares notification key `object_created` with default `enabled: true` +- **AND** user `behandelaar-2` has NO stored override for `(meldingen, object_created)` +- **WHEN** a melding object is created and `behandelaar-2` is a resolved recipient +- **THEN** the system MUST deliver the in-app/push notification to `behandelaar-2` using the schema default + +#### Scenario: Preferences are isolated per user +- **GIVEN** user `behandelaar-1` has an override turning `(meldingen, object_created)` off +- **WHEN** the override is read for user `behandelaar-2` +- **THEN** the system MUST return the schema default for `behandelaar-2` (no value), unaffected by `behandelaar-1`'s override + +### Requirement: Unknown (schema, notification-key) pairs MUST fall through to the schema default with zero migration +The preference-resolution layer MUST be tolerant of unknown keys: adding a NEW schema, or adding a NEW notification to an EXISTING schema, MUST keep working without any migration, data backfill, or rebuild. Any `(schema, notification-key)` pair for which no user override exists MUST resolve to the schema-declared default. The system MUST NOT require a per-user row, table, or migration to exist before a notification can be delivered. + +#### Scenario: New schema works immediately with no migration +- **GIVEN** a brand-new schema `klachten` is saved with an `x-openregister-notifications` `object_created` default of `enabled: true` +- **AND** no user has any stored override for any `klachten` notification +- **WHEN** a `klachten` object is created +- **THEN** the system MUST deliver the notification to resolved recipients using the schema default +- **AND** no migration or preference-table backfill MUST be required + +#### Scenario: New notification added to an existing schema falls through +- **GIVEN** schema `meldingen` already had `object_created` notifications and users had overrides for it +- **WHEN** the schema is updated to ALSO declare an `object_updated` notification with default `enabled: true` +- **AND** users have no override for `(meldingen, object_updated)` +- **THEN** an `object_updated` event MUST deliver to recipients using the new schema default +- **AND** existing `(meldingen, object_created)` overrides MUST remain unaffected + +#### Scenario: Reading an unknown key never errors +- **WHEN** the resolver is asked for the effective preference of a `(schema, notification-key)` pair that has no stored override and that the schema may or may not declare +- **THEN** the resolver MUST return the schema default if the schema declares the key +- **AND** MUST return a safe "no notification" result if the schema does not declare the key, without raising an error + +### Requirement: The effective-preferences API MUST expose schema-default-merged-with-override reads and single-pair override writes +The system MUST expose an API for the per-user settings pane: a GET endpoint returning the EFFECTIVE notifications for the current user (every notification the user's accessible schemas declare, merged with that user's stored overrides), and a PUT endpoint that records or clears a single `(schema, notification-key)` override. The API MUST be authenticated as the current Nextcloud user and MUST only read/write that user's own overrides. + +#### Scenario: GET returns merged effective preferences +- **GIVEN** schema `meldingen` declares `object_created` (default on) and `object_updated` (default off) +- **AND** the current user has an override turning `object_created` off +- **WHEN** the user calls the effective-preferences GET endpoint +- **THEN** the response MUST list `(meldingen, object_created)` as effectively `off` (from the override) +- **AND** MUST list `(meldingen, object_updated)` as effectively `off` (from the schema default) +- **AND** MUST indicate, per entry, whether the effective value came from the schema default or a user override + +#### Scenario: PUT records a single-pair override +- **WHEN** the current user calls the override PUT endpoint for `(meldingen, object_created)` with `enabled: false` +- **THEN** the system MUST persist that override under the `openregister` app user-config for the current user only +- **AND** a subsequent effective-preferences GET MUST reflect the override + +#### Scenario: PUT clearing an override restores the schema default +- **GIVEN** the current user has an override for `(meldingen, object_created)` +- **WHEN** the user calls the override PUT endpoint to clear/reset that pair +- **THEN** the stored user-config value MUST be removed +- **AND** a subsequent effective-preferences GET MUST show the schema default for that pair + +#### Scenario: A user cannot read or write another user's overrides +- **WHEN** an authenticated user calls the effective-preferences GET or override PUT endpoint +- **THEN** the system MUST scope all reads and writes to the authenticated user's own app-config values + +### Requirement: The dispatcher MUST consult the merged preference before delivering the in-app/push channel +Before delivering the in-app (`nc-notification`) or `push` channel to a given recipient, the dispatcher MUST resolve the effective preference for that recipient as `schema-default ⊕ user-override` and MUST skip the recipient when the effective preference is off. The dispatcher MUST evaluate this per recipient so that one recipient's override never affects another's delivery. + +#### Scenario: Recipient with an off override is skipped +- **GIVEN** a schema rule resolves recipients `jan` and `piet` for an `object.created` in-app notification +- **AND** `jan` has an override turning that notification off +- **WHEN** the dispatcher fans out the in-app channel +- **THEN** `jan` MUST NOT receive the notification +- **AND** `piet` MUST receive it (no override → schema default applies) + +#### Scenario: Channel override narrows delivery when supported +- **GIVEN** a schema rule declares both `nc-notification` and `push` channels for a recipient +- **AND** the recipient's override specifies the notification is on but only for the in-app channel +- **WHEN** the dispatcher fans out +- **THEN** the in-app notification MUST be delivered +- **AND** the push channel MUST be suppressed for that recipient +- **AND** if the override specifies on/off only (no channel), both declared channels MUST follow the on/off value + +## MODIFIED Requirements + +### Requirement: The system MUST integrate with Nextcloud's INotificationManager for in-app notifications +All notification delivery to Nextcloud users MUST go through Nextcloud's native `OCP\Notification\IManager` interface. The existing `Notifier` class (implementing `INotifier`) MUST be extended so that `prepare()` renders the object-lifecycle subjects declared by `x-openregister-notifications` — at minimum `object_created`, `object_updated`, and an assignment/transition subject — in addition to the existing `configuration_update_available`. Each rendered subject MUST be internationalised in Dutch (nl) and English (en) via `IFactory::get('openregister', )` and MUST carry a primary action link to the object detail view. Push delivery is achieved by `notify_push` auto-intercepting the same `IManager` notification — no separate push code is required; the `push` channel is declared, not coded. + +#### Scenario: Deliver object creation notification via INotificationManager +- GIVEN a schema-declared `x-openregister-notifications` rule targeting channel `nc-notification` for schema `meldingen` on event `object.created` +- AND user `behandelaar-1` is a member of the recipient group `kcc-team` +- AND `behandelaar-1` has no override for `(meldingen, object_created)` +- WHEN a new melding object is created with title `Overlast Binnenstad` +- THEN the system MUST call `IManager::notify()` with an `INotification` where: + - `app` = `openregister` + - `user` = `behandelaar-1` + - `subject` = `object_created` with parameters including register, schema, object UUID, and object title + - `object` type = `register_object`, id = the object's database ID +- AND the notification MUST appear in the Nextcloud notification bell within 2 seconds +- AND clicking the notification MUST navigate to `/apps/openregister/#/registers/{registerId}/schemas/{schemaId}/objects/{objectUuid}` + +#### Scenario: Notifier renders object_created with nl i18n and action link +- GIVEN the Notifier receives an `INotification` with subject `object_created` and `languageCode` = `nl` +- WHEN `Notifier::prepare()` is called +- THEN it MUST use `IFactory::get('openregister', 'nl')` to load Dutch translations +- AND the parsed subject MUST read the Dutch object-created string with the object title and register name substituted (e.g. `Object "%s" aangemaakt in register "%s"`) +- AND it MUST add a primary action labelled `Bekijken` linking to the object detail view (`openregister.dashboard.page` with fragment `#/registers/{registerId}/schemas/{schemaId}/objects/{objectUuid}`), request type `GET` +- AND the notification icon MUST be set via `IURLGenerator::imagePath('openregister', ...)` + +#### Scenario: Notifier renders object_updated with en i18n and action link +- GIVEN the Notifier receives an `INotification` with subject `object_updated` and `languageCode` = `en` +- WHEN `Notifier::prepare()` is called +- THEN it MUST use `IFactory::get('openregister', 'en')` to load English translations +- AND the parsed subject MUST read the English object-updated string (e.g. `Object "%s" updated in register "%s"`) with the title and register name substituted +- AND it MUST add a primary action labelled `View` linking to the object detail view + +#### Scenario: Notifier renders the assignment/transition subject +- GIVEN the Notifier receives an `INotification` for an assignment/transition subject (e.g. an object's `assignedTo` changed) with a `languageCode` +- WHEN `Notifier::prepare()` is called +- THEN it MUST render a localised subject naming the affected object and the assignment/transition in the recipient's language (nl or en) +- AND it MUST add a primary action linking to the object detail view + +#### Scenario: Unknown subject is left unhandled safely +- GIVEN the Notifier receives an `INotification` whose subject is not one it renders +- WHEN `Notifier::prepare()` is called +- THEN it MUST raise `\InvalidArgumentException` (the documented INotifier contract for unknown subjects) +- AND delivery of other notifications MUST be unaffected + +### Requirement: Users MUST be able to manage their notification preferences +Users MUST be able to turn specific schema-declared notifications on or off (and optionally select channels) via a personal settings interface, without affecting other users' preferences. Preferences MUST be stored as override-only values in Nextcloud per-user app config under the `openregister` app (NOT in a `NotificationPreference` or `NotificationSubscription` table). When a user has no override for a `(schema, notification-key)` pair, the schema-declared default applies. + +#### Scenario: User disables a specific notification +- GIVEN schema `meldingen` declares an `object_created` notification (default on) to group `behandelaars` +- AND user `jan` is a member of `behandelaars` +- WHEN `jan` turns off `(meldingen, object_created)` via the override PUT endpoint +- THEN `jan` MUST NOT receive that notification +- AND other members of `behandelaars` MUST be unaffected + +#### Scenario: Retrieve effective user notification preferences +- GIVEN user `jan` has customised 2 of the notifications his accessible schemas declare +- WHEN `jan` calls the effective-preferences GET endpoint +- THEN the response MUST list every declared notification for his accessible schemas with its effective on/off (and channel) value +- AND for the 2 customised entries the effective value MUST reflect his overrides, with the remainder showing the schema defaults +- AND each entry MUST indicate whether its value came from the schema default or a user override + +#### Scenario: User with no overrides sees all schema defaults +- GIVEN user `piet` has never set any override +- WHEN `piet` calls the effective-preferences GET endpoint +- THEN every entry MUST reflect the schema-declared default +- AND no per-user row or migration MUST be required for the read to succeed + +## REMOVED Requirements + +### Requirement: Notifications MUST support per-register and per-schema channel subscriptions +**Reason**: Superseded by the override-only model. The `NotificationSubscription` entity/table + `NotificationSubscriptionsController` implemented a per-user subscribe/unsubscribe store, which contradicts the constraint that rules live only in the schema annotation and preferences are override-only Nextcloud user-config values. Per-schema channel defaults are now declared in `x-openregister-notifications`; per-user behaviour is an override-only user-config value. +**Migration**: Deprecate `NotificationSubscriptionsController` (mark `@deprecated`, keep responding during the deprecation window). Migrate any existing `NotificationSubscription` rows into equivalent `(schema, notification-key)` user-config overrides via a one-shot repair/migration step, then schedule removal of the table, mapper, and controller. (Deprecate-vs-hard-remove is recorded as a DEFERRED_QUESTION; provisional decision is deprecate-then-remove.) diff --git a/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/tasks.md b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/tasks.md new file mode 100644 index 0000000000..af04b62498 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-notification-schema-rules-and-userconfig-prefs/tasks.md @@ -0,0 +1,48 @@ +## 1. Dispatch in-app/push from the schema annotation + +- [x] 1.1 Add an `AnnotationNotificationDispatcher` seam that, on an object lifecycle event, reads `configuration['x-openregister-notifications']` off the already-loaded `Schema` and selects rules matching the event (no rule table, no persistence). +- [x] 1.2 Resolve recipients for a matching rule (groups / `field` / users per the rule shape) and fan out the `nc-notification` and `push` channels via `OCP\Notification\IManager` (push needs no extra code — `notify_push` intercepts `IManager`). +- [x] 1.3 Leave the existing `NotificationsAnnotationInstaller` webhook-materialisation path unchanged; ensure unimplemented advanced rule fields (digest/threshold/rate-limit) are ignored gracefully, not errored. + +## 2. Render object-lifecycle subjects in Notifier + +- [x] 2.1 Extend `lib/Notification/Notifier.php::prepare()` with cases for `object_created`, `object_updated`, and an assignment/transition subject, keeping unknown subjects raising `\InvalidArgumentException`. +- [x] 2.2 Localise each subject in nl + en via `IFactory::get('openregister', )`; add the matching nl/en translation strings. +- [x] 2.3 Add a primary action (`Bekijken` / `View`, request type GET) linking to the object detail view route, and set the OR app icon via `IURLGenerator::imagePath`. + +## 3. Override-only user-config preferences API + +- [x] 3.1 Add a preferences service that resolves `effective = user-override ?? schema-default` for a `(schema, notification-key)` pair, reading overrides via `IConfig::getUserValue` under app `openregister` (key shape per design.md), returning the schema default for unknown keys with no error. +- [x] 3.2 Add a controller GET endpoint returning the effective notifications for the current user (every declared notification on accessible schemas merged with the user's overrides), tagging each entry as `schema-default` or `user-override`; scope strictly to the authenticated user. +- [x] 3.3 Add a controller PUT endpoint that records a single `(schema, notification-key)` override (on/off, optional `channels`) and clears the stored value when reset; register both routes in `appinfo/routes.php` with correct auth attributes. + +## 4. Gate delivery on the merged preference + +- [x] 4.1 In the dispatcher, before delivering the `nc-notification`/`push` channel to each recipient, resolve `schema-default ⊕ user-override` per recipient and skip recipients whose effective value is off; apply optional channel-narrowing when present. + +## 5. Deprecate the NotificationSubscription layer + +- [x] 5.1 Mark `NotificationSubscriptionsController` (and the entity/mapper) `@deprecated` and add a one-shot repair/migration translating existing `NotificationSubscription` rows into equivalent user-config overrides (idempotent; original rows left in place during the deprecation window). + +## 6. Document shapes and verify + +- [x] 6.1 Document the canonical `x-openregister-notifications` rule shape and the user-config override key shape (from design.md) in the app docs / code docblocks using SAFE placeholders (nil UUID, `YOUR_*_HERE`); no new OR schema or seed-data file is added. +- [x] 6.2 Add tests covering the zero-migration fall-through invariant, the merged-preference delivery gate (per-recipient isolation), and nl/en Notifier rendering with action links. + +## Acceptance criteria + +- Notification rules are read only from `x-openregister-notifications` at dispatch time; no `NotificationRule` table or entity exists. +- A schema-declared `object.created` in-app/push notification reaches the recipient's Nextcloud bell and renders localised (nl/en) with a working object-detail action link. +- User preferences are override-only Nextcloud user-config values under app `openregister`; a stored override flips only the schema default for one `(schema, notification-key)` pair; absence of an override falls through to the schema default. +- Adding a new schema, or a new notification to an existing schema, delivers correctly with zero migration / backfill. +- The effective-GET endpoint returns schema-defaults merged with the current user's overrides (tagged by source); the override-PUT writes/clears a single pair, scoped to the authenticated user only. +- The dispatcher skips recipients whose effective preference is off, per recipient, without affecting other recipients. +- `NotificationSubscriptionsController`/entity/mapper are deprecated and existing rows are migrated to user-config overrides. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- All new PHP files carry the SPDX-License-Identifier + SPDX-FileCopyrightText inside the main docblock (EUPL-1.2). +- All framework interactions use OCP interfaces (`IManager`, `INotifier`, `IFactory`, `IConfig`, `IURLGenerator`). +- nl + en translation strings exist for every new Notifier subject and action label. +- No new DB tables or OR schemas introduced; push relies on `notify_push` interception (no bespoke push code). diff --git a/openspec/changes/notification-updated-field-change-condition/.openspec.yaml b/openspec/changes/notification-updated-field-change-condition/.openspec.yaml new file mode 100644 index 0000000000..9e883bff04 --- /dev/null +++ b/openspec/changes/notification-updated-field-change-condition/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/notification-updated-field-change-condition/design.md b/openspec/changes/notification-updated-field-change-condition/design.md new file mode 100644 index 0000000000..3b4c723692 --- /dev/null +++ b/openspec/changes/notification-updated-field-change-condition/design.md @@ -0,0 +1,51 @@ +## Context + +The `notificatie-engine` dispatcher (`lib/Service/Notification/AnnotationNotificationDispatcher.php`) reads the `x-openregister-notifications` schema annotation and fires Nextcloud notifications when a triggering event matches. Its `matches()` method handles four trigger types: `created`, `updated`, `transition`, and `calculatedChange`. Today the `updated` trigger fires on *every* update unconditionally, and the only conditional path — `calculatedChange` — is numeric-only (`numericConditionMatches()` casts to float for ordering operators). The single most-requested fleet pattern is "notify when a field *changed to X*" (status moved, lead won/lost, assignee re-assigned). That is a non-numeric field-change check, which the engine cannot express today. + +The listener (`lib/Listener/AnnotationNotificationListener.php`) already has both the old and new object on `ObjectUpdatedEvent` (`getOldObject()` / `getNewObject()`). It already passes `_oldData`/`_newData` into the dispatch context for the `calculatedChange` trigger, but the plain `updated` dispatch is called with no context. So the data the new condition needs is already in hand — it just isn't forwarded on the `updated` path. + +## Goals / Non-Goals + +**Goals:** +- Add an optional non-numeric field-change `condition` to the `updated` trigger: `changed`, `equals` (with optional `from`). +- Evaluate the condition against the same old-vs-new object data the engine already uses for `calculatedChange`. +- Fail closed when old/new data is absent, mirroring `calculatedChange`. +- Preserve back-compat: condition-less `updated` rules keep firing on every update. + +**Non-Goals:** +- No new numeric operators or changes to `calculatedChange`/`numericConditionMatches()` semantics. +- No new schemas, DB tables, entities, or migrations — this is engine code interpreting a declarative annotation. +- No multi-field / boolean-combinator conditions (single `field` + single `operator` only in this change). +- No changes to recipient resolution, channels, rate-limiting, coalescing, or preferences. + +## Declarative-vs-imperative + +Per ADR-031, schema-declared behaviour is the declarative source of truth and imperative code is justified only where it *interprets* that declaration. This change is squarely on the justified side: the `condition` block is authored declaratively inside the `x-openregister-notifications` annotation (`{"field": "status", "operator": "changed"}`), exactly like the existing `calculatedChange` `condition`/`previously` blocks. The PHP we add is the *engine* that reads and evaluates those declarations at dispatch time — it introduces no new imperative business rule of its own. No schema author writes PHP; they only add a declarative `condition` to their annotation. The imperative evaluator is the interpreter for a declarative DSL, which ADR-031 explicitly permits for engine code. + +## Seed Data + +No seed data, no new schemas, no new tables. This change is pure engine code: it extends `matches()` (an evaluator) and forwards already-available data through the listener's `updated` dispatch. There is nothing to seed — the feature activates purely by schema authors adding an optional `condition` block to an existing `updated` rule in their own schema annotation. Existing seeded schemas are unaffected (condition-less rules behave exactly as before). + +## Decisions + +**Decision: Add a string-condition evaluator beside `numericConditionMatches()`, do not overload it.** +`numericConditionMatches()` casts to float for `lt`/`lte`/`gt`/`gte` and to string for `eq`/`ne`; its contract is numeric thresholds. The new operators (`changed`, `equals` + optional `from`) are non-numeric string comparisons over an old/new pair, not a single value against a threshold. Overloading the numeric evaluator would muddy both contracts. Instead, `matches()` gains an `updated`-branch that, when the `trigger` declares a `condition`, reads `_oldData`/`_newData` from context and delegates to a new small private string-condition evaluator (e.g. `fieldChangeConditionMatches()`). *Alternative considered:* reuse `calculatedChange` entirely by aliasing — rejected because `calculatedChange` is a separate trigger type with `condition`+`previously` numeric semantics, and apps want the plain `updated` event (which already fires for every save) to carry the field-change filter without re-declaring as `calculatedChange`. + +**Decision: Forward `_oldData`/`_newData` on the plain `updated` dispatch in the listener.** +`AnnotationNotificationListener::handle()` already extracts both objects and already builds the `_newData`/`_oldData` context for the `calculatedChange` dispatch. Extend the `updated` dispatch call to pass the same context (when an old object is available). This is the minimal, symmetric change — the data is already in hand. *Alternative considered:* have the dispatcher re-read versioned history to reconstruct the old value — rejected as redundant I/O when the listener already holds the old object. + +**Decision: Fail closed when old/new data is absent.** +When `_oldData`/`_newData` are not arrays in the context (e.g. no previous object, or a non-update code path that reuses the dispatcher), a `condition`-bearing `updated` rule MUST NOT fire. This mirrors the existing `calculatedChange` guard (`is_array($newData) === false || is_array($oldData) === false` → return false). Failing closed avoids spurious fires when the engine cannot actually evaluate the declared condition. + +**Decision: Condition-less `updated` rules stay unconditional (back-compat).** +The new logic only engages when the `trigger` block contains a `condition` key. A rule with no `condition` skips the evaluator entirely and matches on trigger-type alone, exactly as today. No existing rule changes behaviour. + +## Risks / Trade-offs + +- **Risk: a non-update caller invokes the dispatcher with `updated` but no old/new context, silently suppressing a condition rule.** → Mitigated by the fail-closed contract being explicit in the spec scenario and unit-tested; condition-less rules (the legacy shape) are unaffected. +- **Risk: type coercion mismatch between old/new values (e.g. `"1"` vs `1`) causes `changed` to mis-fire.** → Mitigated by defining `changed`/`equals` as string-normalised comparison in the evaluator, consistent with how `numericConditionMatches()` already string-casts for `eq`/`ne`; documented in the evaluator docblock. +- **Trade-off: single-field, single-operator only.** Multi-field / AND-OR composition is deferred; acceptable because the fleet demand (status/assignee change) is single-field. If composition is later needed it extends the same evaluator without breaking this shape. + +## Migration Plan + +No migration. Engine-only change, back-compatible. Deploy is a code update; rollback is reverting the two touched files — no data or schema state is affected. diff --git a/openspec/changes/notification-updated-field-change-condition/proposal.md b/openspec/changes/notification-updated-field-change-condition/proposal.md new file mode 100644 index 0000000000..a23264ec03 --- /dev/null +++ b/openspec/changes/notification-updated-field-change-condition/proposal.md @@ -0,0 +1,34 @@ +--- +kind: code +depends_on: [] +--- + +# Notification `updated` Trigger — Field-Change Condition + +## Why + +The fleet notification analysis (hydra/openspec/fleet-notification-plan.md) found that the single most-wanted notification pattern across every app is *"notify when a field changed to X"* — case/zaak status moved, a lead was won/lost, an assignee was re-assigned, a signing request was signed. The `notificatie-engine` dispatcher's `updated` trigger today fires on **every** update with no condition, and the only conditional path (`calculatedChange`) is **numeric-only**. So precise status/assignee-change rules are not expressible, forcing apps into noisy "every update" rules or awkward `transition`/`scheduled` workarounds. This is the highest-leverage gap blocking the per-app rollout. + +## What Changes + +- Extend the `updated` trigger in `AnnotationNotificationDispatcher::matches()` with an optional **`condition`** block that evaluates a (non-numeric) field change against the old vs new object data the listener already supplies: + - `{"field": "status", "operator": "changed"}` — fires only when the field's value differs between old and new. + - `{"field": "status", "operator": "equals", "value": "afgehandeld"}` — fires only when the new value equals `value` (optionally also `"from"` to require the prior value). + - `{"field": "assignee", "operator": "changed"}` — the assignee-reassignment case the fleet needs. +- The `AnnotationNotificationListener` already dispatches `updated` with the new object; extend it to also pass `_oldData`/`_newData` for the plain `updated` trigger (it already does this for `calculatedChange`), so `matches()` can compare without re-reading history. +- When `_oldData`/`_newData` are unavailable (e.g. no old object), a `condition`-bearing `updated` rule **fails closed** (does not fire) — consistent with the existing `calculatedChange` behaviour. +- Rules with **no** `condition` keep firing on every update (back-compatible). + +## Capabilities + +### Modified Capabilities +- `notificatie-engine`: the `updated` trigger gains an optional non-numeric field-change `condition` (`changed` / `equals` (+optional `from`)), evaluated against old-vs-new object data. Unblocks status/assignee-change notifications fleet-wide. + +## Impact + +- **Code (OpenRegister):** + - `lib/Service/Notification/AnnotationNotificationDispatcher.php` — `matches()` evaluates the new `condition` block for `updated`; a small string-condition evaluator alongside the existing numeric `numericConditionMatches()`. + - `lib/Listener/AnnotationNotificationListener.php` — pass `_oldData`/`_newData` in the `updated` dispatch context. + - Tests in `tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php`. +- **No** new DB tables / schemas. Back-compatible: existing `updated` rules without `condition` are unaffected. +- **Unblocks** the per-app notification change requests (procest reassignment, zaakafhandelapp status transitions, pipelinq won/lost, docudesk signer-signed, openconnector status→error) that were deferred to this engine gap. diff --git a/openspec/changes/notification-updated-field-change-condition/specs/notificatie-engine/spec.md b/openspec/changes/notification-updated-field-change-condition/specs/notificatie-engine/spec.md new file mode 100644 index 0000000000..78dda02053 --- /dev/null +++ b/openspec/changes/notification-updated-field-change-condition/specs/notificatie-engine/spec.md @@ -0,0 +1,77 @@ +## MODIFIED Requirements + +### Requirement: The notification engine MUST support event-driven trigger types beyond CRUD +Notifications MUST be triggerable by workflow events, threshold alerts, scheduled checks, and external triggers in addition to standard object CRUD events. + +The `updated` trigger MUST additionally accept an optional non-numeric field-change `condition` block, evaluated against the old-versus-new object data the dispatch already supplies for `calculatedChange`. The block names a single `field` and one `operator`: + +- `{"field": "status", "operator": "changed"}` — the rule fires only when the field's value differs between the old and new object data (old ≠ new). +- `{"field": "status", "operator": "equals", "value": ""}` — the rule fires only when the new value equals `value`. +- `{"field": "status", "operator": "equals", "value": "", "from": ""}` — the optional `from` additionally requires the old value to equal ``, so the rule fires only on the specific `` → `` transition. + +The evaluator MUST fail closed: when the old-versus-new object data is unavailable in the dispatch context, a `condition`-bearing `updated` rule MUST NOT fire — consistent with the existing `calculatedChange` behaviour. An `updated` rule that declares NO `condition` MUST continue to fire on every update (back-compatible). The non-numeric field-change condition is evaluated by a string-condition evaluator distinct from the existing numeric `calculatedChange` evaluator; numeric `calculatedChange` semantics are unchanged. + +#### Scenario: Workflow completion triggers notification +- GIVEN an n8n workflow `vergunning-beoordeling` completes with output `{"result": "goedgekeurd"}` +- AND a notification rule listens for event `workflow.completed` with condition `{"workflowName": "vergunning-beoordeling"}` +- WHEN the workflow completes +- THEN a notification MUST be sent to the assignee with message: `Vergunning {{object.title}} is goedgekeurd` + +#### Scenario: Threshold alert triggers notification +- GIVEN a notification rule with trigger type `threshold`: + - `schema`: `meldingen` + - `condition`: `{"aggregate": "count", "operator": ">=", "value": 100, "period": "24h"}` + - `template`: `Waarschuwing: {{count}} meldingen in de afgelopen 24 uur` +- WHEN the 100th melding is created within 24 hours +- THEN a threshold notification MUST be sent to the configured recipients +- AND the notification MUST include the actual count + +#### Scenario: SLA deadline approaching triggers notification +- GIVEN a notification rule with trigger type `deadline`: + - `schema`: `vergunningen` + - `condition`: `{"field": "deadline", "operator": "before", "offset": "-48h"}` + - `template`: `Vergunning "{{object.title}}" nadert deadline ({{object.deadline}})` +- WHEN a background job detects that object `vergunning-1` has a deadline within 48 hours +- THEN a notification MUST be sent to `object.assignedTo` with the deadline warning + +#### Scenario: External system triggers notification via API +- GIVEN notification rule 15 is configured to accept external triggers +- WHEN an external system calls `POST /api/notification-rules/15/trigger` with payload `{"objectUuid": "abc-123", "message": "Externe update ontvangen"}` +- THEN a notification MUST be sent to the rule's recipients with the provided message + +#### Scenario: updated trigger with `changed` condition fires only when the field value differs +- GIVEN an `updated` rule whose `trigger` declares `condition` `{"field": "status", "operator": "changed"}` +- AND the dispatch context carries the old object data `{"status": "open"}` and the new object data `{"status": "closed"}` +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST fire because the old value (`open`) differs from the new value (`closed`) + +#### Scenario: updated trigger with `changed` condition does not fire when the field value is unchanged +- GIVEN an `updated` rule whose `trigger` declares `condition` `{"field": "status", "operator": "changed"}` +- AND the dispatch context carries the old object data `{"status": "open"}` and the new object data `{"status": "open"}` +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST NOT fire because the old value equals the new value + +#### Scenario: updated trigger with `equals` condition fires only when the new value matches +- GIVEN an `updated` rule whose `trigger` declares `condition` `{"field": "status", "operator": "equals", "value": "closed"}` +- AND the dispatch context carries the old object data `{"status": "open"}` and the new object data `{"status": "closed"}` +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST fire because the new value equals `closed` +- AND GIVEN instead a new object data of `{"status": "pending"}`, the rule MUST NOT fire + +#### Scenario: updated trigger with optional `from` requires the prior value +- GIVEN an `updated` rule whose `trigger` declares `condition` `{"field": "status", "operator": "equals", "value": "closed", "from": "open"}` +- AND the dispatch context carries the old object data `{"status": "open"}` and the new object data `{"status": "closed"}` +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST fire because the new value equals `closed` AND the old value equals `open` +- AND GIVEN instead an old object data of `{"status": "pending"}`, the rule MUST NOT fire because the prior value does not equal `open` + +#### Scenario: condition-bearing updated rule fails closed when old/new data is unavailable +- GIVEN an `updated` rule whose `trigger` declares any field-change `condition` +- AND the dispatch context does NOT carry the old and new object data (e.g. no previous object was available) +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST NOT fire, matching the fail-closed behaviour of `calculatedChange` + +#### Scenario: updated rule with no condition still fires on every update +- GIVEN an `updated` rule whose `trigger` declares NO `condition` block +- WHEN any update occurs on the object +- THEN the rule MUST fire on every update, preserving backwards compatibility with existing condition-less rules diff --git a/openspec/changes/notification-updated-field-change-condition/tasks.md b/openspec/changes/notification-updated-field-change-condition/tasks.md new file mode 100644 index 0000000000..307f6f3c8a --- /dev/null +++ b/openspec/changes/notification-updated-field-change-condition/tasks.md @@ -0,0 +1,35 @@ +## 1. Listener: forward old/new data on the `updated` dispatch + +- [x] 1.1 In `AnnotationNotificationListener::handle()`, pass `_newData`/`_oldData` context on the plain `updated` dispatch (when an old object is available), mirroring the existing `calculatedChange` dispatch. + +## 2. Dispatcher: evaluate the field-change condition + +- [x] 2.1 In `AnnotationNotificationDispatcher::matches()`, add an `updated`-trigger branch that engages only when the `trigger` block declares a `condition`. +- [x] 2.2 Read `_oldData`/`_newData` from context in that branch; when either is not an array, return false (fail-closed). +- [x] 2.3 Add a private string-condition evaluator (e.g. `fieldChangeConditionMatches()`) beside `numericConditionMatches()` that compares the field's old vs new value. +- [x] 2.4 Implement operator `changed`: matches only when the old value differs from the new value (string-normalised comparison). +- [x] 2.5 Implement operator `equals`: matches only when the new value equals `value`; when optional `from` is present, also require the old value to equal `from`. +- [x] 2.6 Ensure a `condition`-less `updated` rule skips the evaluator entirely and matches on trigger-type alone (back-compat). + +## 3. Unit tests + +- [x] 3.1 `changed` fires when old != new; does NOT fire when old == new. +- [x] 3.2 `equals` fires when new == value; does NOT fire when new != value. +- [x] 3.3 `equals` with `from` fires only on the declared `from` -> `value` transition; does NOT fire when the prior value differs. +- [x] 3.4 Missing `_oldData`/`_newData` -> a `condition`-bearing rule does NOT fire (fail-closed). +- [x] 3.5 A `condition`-less `updated` rule still fires on every update. +- [x] 3.6 Listener test: the `updated` dispatch receives `_newData`/`_oldData` when an old object is present. + +## Acceptance criteria + +- The `updated` trigger accepts an optional non-numeric field-change `condition` (`changed`, `equals` with optional `from`), evaluated against old-vs-new object data. +- Condition-bearing rules fail closed when old/new data is absent, matching `calculatedChange`. +- Condition-less `updated` rules are unchanged and fire on every update. +- `calculatedChange` / `numericConditionMatches()` numeric semantics are untouched. +- No new schemas, tables, entities, or migrations are introduced. + +## Quality items + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan) with no new violations. +- New PHPUnit tests pass and existing notification dispatcher tests remain green. +- No regressions verified against opencatalogi and softwarecatalog notification rules. diff --git a/openspec/changes/openregister-system-notifications/.openspec.yaml b/openspec/changes/openregister-system-notifications/.openspec.yaml new file mode 100644 index 0000000000..9e883bff04 --- /dev/null +++ b/openspec/changes/openregister-system-notifications/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/openregister-system-notifications/design.md b/openspec/changes/openregister-system-notifications/design.md new file mode 100644 index 0000000000..88cf26adbc --- /dev/null +++ b/openspec/changes/openregister-system-notifications/design.md @@ -0,0 +1,80 @@ +# Design — OpenRegister System-Schema Notifications + +## Context + +OpenRegister owns the notification engine (`notificatie-engine`). The engine +fires schema-declared `x-openregister-notifications` rules off +`ObjectCreatedEvent` / `ObjectUpdatedEvent` / `ObjectTransitionedEvent`, with +the dispatcher resolving rules from a stored `ObjectEntity` via +`$object->getSchema()` → `SchemaMapper`. OpenRegister's own system entities +(`Register`, `Schema`, `Configuration`, `Source`, `Synchronization`, `Import`, +`Webhook`, `Agent`) are plain `OCP\AppFramework\Db\Entity` records and do NOT +flow through those events — so today no operational event on OpenRegister's own +domain can drive a notification. + +## Goal + +Let OpenRegister notify platform admins / integration ops about its own +operational events (sync/import failure, schema/config change, source/agent +health) using the **same** engine (channels, recipients, preferences, i18n, +rate-limiting, coalescing) as every other app — without forking a parallel +notification path. + +## Decision: kind = code + +Annotation alone cannot work because the system entities never reach the +dispatcher. Two pieces of engine wiring are required: + +1. **System-schema rule source.** The dispatcher must be able to resolve + `x-openregister-notifications` for a system entity. Two viable shapes + (decide during implementation): + - **(a) Synthetic system schemas** — seed real `Schema` rows for the system + entities and attach annotations, so the existing + `SchemaMapper`-based lookup just works. Cleanest reuse; needs the system + entities to be addressable as schema-backed objects. + - **(b) System-rule registry** — a small in-code map of system-schema-slug → + rule array the dispatcher consults when the entity is a system entity. + Less invasive; duplicates a little of the schema-lookup path. + Prefer (a) if the system entities can be represented as schema-backed + objects without distorting the data model; fall back to (b) otherwise. + +2. **System-event bridge.** Emit (or adapt existing) create/update/transition + signals for the system entities and route them through + `AnnotationNotificationListener`, populating `_oldData`/`_newData` so the + `notification-updated-field-change-condition` `condition` block (status / + health-field changes) is available for system schemas too. + +## Recipients & subjects + +- Recipients: integration-ops / `admin` groups (`{"kind":"groups",...}`), + schema/config owners (`{"kind":"object-acl","permission":"manage"}` or an + owner field). No external email needed (all internal uids/groups). +- Subjects: bilingual nl/en, metadata-only (schema name, source name, run id) — + never payload contents. + +## Triggers per event + +| Event | Trigger | Notes | +|-------|---------|-------| +| Sync failure | `transition`→`failed` **or** `updated`+`condition` equals `failed` | depends on whether Synchronization has named lifecycle actions | +| Import failure | `transition`→`failed` **or** `updated`+`condition` | same | +| Schema changed | `updated` (optionally `condition` on a version field) | owners + admin | +| Config changed | `updated` | admin group | +| Source unhealthy | `threshold` (consecutive failures) **or** `updated`+`condition` health field | numeric threshold preferred if a failure counter exists | +| Agent unhealthy | `threshold` / `updated`+`condition` heartbeat field | | + +## Open questions (resolve in implementation) + +1. Synthetic system schemas (a) vs system-rule registry (b)? +2. Canonical slug/identifier for each system schema. +3. Which system entities already emit create/update/transition signals vs need + new event emission (esp. Synchronization/Import run outcomes, Source/Agent + health checks). +4. Whether Source/Agent health is best modelled as `threshold` (needs a numeric + failure counter) or `updated`+`condition` on a health field. + +## Non-goals + +- No change to stored-object notification behaviour. +- No new user-facing register JSON under `lib/Settings/`. +- No new external-email channel (out of scope; tracked in the engine gap notes). diff --git a/openspec/changes/openregister-system-notifications/proposal.md b/openspec/changes/openregister-system-notifications/proposal.md new file mode 100644 index 0000000000..61a5461357 --- /dev/null +++ b/openspec/changes/openregister-system-notifications/proposal.md @@ -0,0 +1,111 @@ +--- +kind: code +depends_on: [notification-updated-field-change-condition] +--- + +# OpenRegister System-Schema Notifications + +## Why + +The fleet notification analysis (`hydra/openspec/fleet-notification-plan.md`, +openregister row) calls for OpenRegister to notify platform admins and +integration ops about its **own operational domain events** — synchronisation +and import failures, schema/configuration changes, and unhealthy +sources/agents. OpenRegister owns the notification engine, so it is the natural +place to prove that the engine can dispatch on the platform's own system +schemas. + +**The blocking open question (must be answered before any annotation has +effect):** the dispatch path resolves rules from an `ObjectEntity` via +`$object->getSchema()` → `SchemaMapper`, and the listener subscribes to +`ObjectCreatedEvent` / `ObjectUpdatedEvent` / `ObjectTransitionedEvent`. Those +events are emitted for **stored register objects**, not for OpenRegister's own +system entities (`Register`, `Schema`, `Configuration`, `Source`, +`Synchronization`, `Import`, `Webhook`, `Agent`), which are plain +`OCP\AppFramework\Db\Entity` records in `oc_openregister_*` tables and never +flow through `ObjectCreatedEvent` et al. **Verification confirmed the engine +does NOT currently dispatch on OpenRegister's own system schemas.** So a +pure-annotation approach is not sufficient here, and this change is `kind: +code`: it adds the engine wiring (system-schema event source + a system-schema +rule source) so OpenRegister's operational events can drive notifications, then +declares the recommended rule set. + +System-schema slugs below are marked **"(system schema — confirm slug)"** +because OpenRegister's system entities are not currently registered as +annotatable schemas in a user register; the implementation must decide and fix +the canonical slug/identifier for each system schema as part of the wiring. + +## What Changes + +### Engine wiring (the prerequisite) + +- Add a **system-schema notification source**: a way for OpenRegister's own + system entities to carry `x-openregister-notifications` rules (either a + built-in system register/schema set seeded for the system entities, or a + system-schema rule registry the dispatcher consults), so the dispatcher's + rule lookup can resolve rules for a system entity the same way it resolves + them for a stored `ObjectEntity`. +- Add a **system-event bridge**: emit (or adapt) create/update/transition + events for the relevant system entities (`Synchronization`/`Import` run + outcomes, `Schema`/`Configuration` saves, `Source`/`Agent` health) and route + them through `AnnotationNotificationListener` → `AnnotationNotification + Dispatcher` with `_oldData`/`_newData` populated so the + `notification-updated-field-change-condition` `condition` block works for + system schemas too. +- Reuse the existing channels, recipient resolvers, rate-limiting, + coalescing, preference-override and i18n machinery unchanged — only the rule + source and event source are extended to system schemas. + +### Recommended rule set (target state, once wiring lands) + +Declared via `x-openregister-notifications` on the system schemas: + +- **Synchronization failure** (`synchronization` system schema — confirm slug): + `transition` → `failed` action, OR `updated` + `condition` + `{"field":"status","operator":"equals","value":"failed"}` → integration-ops + group + the sync's owner. Recipients `{"kind":"groups","groups":["admin"]}` + (+ owner field if present). +- **Import failure** (`import` system schema — confirm slug): same shape — + `transition`→`failed` or `updated`+condition → integration-ops group. +- **Schema changed** (`schema` system schema — confirm slug): `updated` (no + condition, or `condition` on a version field) → schema owners / + `{"kind":"object-acl","permission":"manage"}` + admin group. +- **Configuration changed** (`configuration` system schema — confirm slug): + `updated` → admin group (`{"kind":"groups","groups":["admin"]}`). +- **Source unhealthy** (`source` system schema — confirm slug): `threshold` + aggregation on consecutive failures, OR `updated`+condition on a health + field → integration-ops group. +- **Agent unhealthy** (`agent` system schema — confirm slug): `threshold` / + `updated`+condition on a health/heartbeat field → integration-ops group. + +Subjects are bilingual (`nl`/`en`) and metadata-only (no payload contents). + +## Capabilities + +### Modified Capabilities + +- `notificatie-engine`: the dispatch path is extended so that OpenRegister's + **own system schemas** (register/schema/configuration/source/synchronization/ + import/webhook/agent) can declare and fire `x-openregister-notifications` + rules for operational events, via a system-schema rule source and a + system-event bridge, reusing all existing channels/recipients/preferences/ + i18n. Builds on `notification-updated-field-change-condition` so + status/health-field-change conditions apply to system schemas. + +## Impact + +- **Code (OpenRegister):** new system-event bridge + system-schema rule source + feeding `AnnotationNotificationListener` / `AnnotationNotificationDispatcher`; + wiring for the relevant system entities (`Synchronization`, `Import`, + `Schema`, `Configuration`, `Source`, `Agent`). No new user-facing schemas in + a `lib/Settings/*_register.json`; system-schema identifiers fixed during + implementation. +- **Open question to resolve in implementation:** confirm whether system + entities get a synthetic system register/schema (annotatable like a normal + schema) or a dedicated system-rule registry; confirm the canonical slug for + each system schema; confirm which system entities already emit usable + create/update/transition signals vs. need new event emission. +- **No** changes to existing stored-object notification behaviour. Back-compat: + apps that annotate their own user schemas are unaffected. +- **Branch model:** left in the working tree on the current branch — no + commit/PR from this task (OpenRegister's branch model is handled separately). diff --git a/openspec/changes/openregister-system-notifications/specs/notificatie-engine/spec.md b/openspec/changes/openregister-system-notifications/specs/notificatie-engine/spec.md new file mode 100644 index 0000000000..3c8c4bcd9d --- /dev/null +++ b/openspec/changes/openregister-system-notifications/specs/notificatie-engine/spec.md @@ -0,0 +1,95 @@ +## MODIFIED Requirements + +### Requirement: Notification rules MUST be sourced ONLY from the schema annotation, evaluated at dispatch time +The system MUST treat `configuration['x-openregister-notifications']` on the `Schema` as the single, authoritative source of notification rules. Because the schema is ALWAYS loaded whenever anything happens to an object, the dispatcher MUST evaluate the notification rules directly from the already-loaded schema annotation at dispatch time. The system MUST NOT persist notification rules in any separate rule table, and MUST NOT introduce a `NotificationRule` entity or `oc_openregister_notification_rules` table. (ADR-031: `x-openregister-notifications` is the declarative replacement for app-local notification service code.) + +OpenRegister's **own system schemas** (`register`, `schema`, `configuration`, `source`, `synchronization`, `import`, `webhook`, `agent`) MUST also be able to declare `x-openregister-notifications` rules for operational events. The dispatcher MUST resolve rules for a system entity through the same annotation-sourced path it uses for stored register objects — either by representing the system entities as schema-backed objects whose schema carries the annotation, or by a system-schema rule source that returns the same rule shape — so that no separate notification-rule table is introduced for system schemas either. + +#### Scenario: Dispatcher reads rules from the loaded schema, not a rule table +- **WHEN** an object lifecycle event fires for an object whose schema declares an `x-openregister-notifications` rule on `object.created` +- **THEN** the dispatcher MUST evaluate that rule from the schema annotation already loaded for the object +- **AND** the system MUST NOT query any notification-rule table (none exists) + +#### Scenario: Editing the schema annotation changes dispatch behaviour immediately +- **WHEN** an administrator updates `x-openregister-notifications` on a schema to add a new `object.updated` rule and saves the schema +- **THEN** the next `object.updated` event on that schema MUST be evaluated against the new rule +- **AND** no rule-table row creation, migration, or rebuild step is required for the change to take effect + +#### Scenario: A system schema declares rules sourced through the same annotation path +- **GIVEN** OpenRegister's `synchronization` system schema declares an `x-openregister-notifications` rule for its failure event +- **WHEN** a synchronization run fails +- **THEN** the dispatcher MUST resolve that rule through the annotation-sourced path (schema-backed object or system-schema rule source), NOT from any notification-rule table +- **AND** the system MUST NOT introduce a notification-rule table for system schemas + +### Requirement: The notification engine MUST support event-driven trigger types beyond CRUD +Notifications MUST be triggerable by workflow events, threshold alerts, scheduled checks, and external triggers in addition to standard object CRUD events. + +The `updated` trigger MUST additionally accept an optional non-numeric field-change `condition` block, evaluated against the old-versus-new object data the dispatch already supplies for `calculatedChange`. The block names a single `field` and one `operator`: + +- `{"field": "status", "operator": "changed"}` — the rule fires only when the field's value differs between the old and new object data (old ≠ new). +- `{"field": "status", "operator": "equals", "value": ""}` — the rule fires only when the new value equals `value`. +- `{"field": "status", "operator": "equals", "value": "", "from": ""}` — the optional `from` additionally requires the old value to equal ``, so the rule fires only on the specific `` → `` transition. + +The evaluator MUST fail closed: when the old-versus-new object data is unavailable in the dispatch context, a `condition`-bearing `updated` rule MUST NOT fire — consistent with the existing `calculatedChange` behaviour. An `updated` rule that declares NO `condition` MUST continue to fire on every update (back-compatible). The non-numeric field-change condition is evaluated by a string-condition evaluator distinct from the existing numeric `calculatedChange` evaluator; numeric `calculatedChange` semantics are unchanged. + +The engine MUST additionally be able to dispatch these trigger types for OpenRegister's **own system entities** (`synchronization`, `import`, `schema`, `configuration`, `source`, `agent`, `webhook`, `register`). A system-event bridge MUST route create/update/transition signals from the relevant system entities through the same `AnnotationNotificationListener` → dispatcher path used for stored register objects, populating the old-versus-new object data so the field-change `condition` block is available for system schemas as well. Operational notifications on system schemas MUST reuse the existing channels, recipient resolvers, rate-limiting, coalescing, per-user preference overrides, and bilingual (nl/en) i18n unchanged; only the rule source and event source are extended to cover system schemas. + +#### Scenario: Workflow completion triggers notification +- GIVEN an n8n workflow `vergunning-beoordeling` completes with output `{"result": "goedgekeurd"}` +- AND a notification rule listens for event `workflow.completed` with condition `{"workflowName": "vergunning-beoordeling"}` +- WHEN the workflow completes +- THEN a notification MUST be sent to the assignee with message: `Vergunning {{object.title}} is goedgekeurd` + +#### Scenario: Threshold alert triggers notification +- GIVEN a notification rule with trigger type `threshold`: + - `schema`: `meldingen` + - `condition`: `{"aggregate": "count", "operator": ">=", "value": 100, "period": "24h"}` + - `template`: `Waarschuwing: {{count}} meldingen in de afgelopen 24 uur` +- WHEN the 100th melding is created within 24 hours +- THEN a threshold notification MUST be sent to the configured recipients +- AND the notification MUST include the actual count + +#### Scenario: SLA deadline approaching triggers notification +- GIVEN a notification rule with trigger type `deadline`: + - `schema`: `vergunningen` + - `condition`: `{"field": "deadline", "operator": "before", "offset": "-48h"}` + - `template`: `Vergunning "{{object.title}}" nadert deadline ({{object.deadline}})` +- WHEN a background job detects that object `vergunning-1` has a deadline within 48 hours +- THEN a notification MUST be sent to `object.assignedTo` with the deadline warning + +#### Scenario: External system triggers notification via API +- GIVEN notification rule 15 is configured to accept external triggers +- WHEN an external system calls `POST /api/notification-rules/15/trigger` with payload `{"objectUuid": "abc-123", "message": "Externe update ontvangen"}` +- THEN a notification MUST be sent to the rule's recipients with the provided message + +#### Scenario: updated trigger with `changed` condition fires only when the field value differs +- GIVEN an `updated` rule whose `trigger` declares `condition` `{"field": "status", "operator": "changed"}` +- AND the dispatch context carries the old object data `{"status": "open"}` and the new object data `{"status": "closed"}` +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST fire because the old value (`open`) differs from the new value (`closed`) + +#### Scenario: updated trigger with `equals` condition fires only when the new value matches +- GIVEN an `updated` rule whose `trigger` declares `condition` `{"field": "status", "operator": "equals", "value": "closed"}` +- AND the dispatch context carries the old object data `{"status": "open"}` and the new object data `{"status": "closed"}` +- WHEN the dispatcher evaluates the rule +- THEN the rule MUST fire because the new value equals `closed` +- AND GIVEN instead a new object data of `{"status": "pending"}`, the rule MUST NOT fire + +#### Scenario: System synchronization failure dispatches an operational notification +- GIVEN OpenRegister's `synchronization` system schema declares an `x-openregister-notifications` rule that fires on the synchronization-failed event (via `transition`→`failed` or `updated`+`condition` `{"field":"status","operator":"equals","value":"failed"}`) with recipients `{"kind":"groups","groups":["admin"]}` +- WHEN a synchronization run transitions to `failed` +- THEN the system-event bridge MUST route the failure through the same listener/dispatcher path used for stored register objects +- AND a notification MUST be delivered to the configured admin/integration-ops group on the configured channel +- AND the subject MUST be metadata-only (no synchronization payload contents) and available in both `nl` and `en` + +#### Scenario: System schema/configuration change dispatches an operational notification +- GIVEN OpenRegister's `configuration` system schema declares an `updated` rule with recipients `{"kind":"groups","groups":["admin"]}` +- WHEN a configuration record is updated +- THEN the system-event bridge MUST dispatch the rule through the existing dispatcher path +- AND a notification MUST be delivered to the admin group, reusing the existing rate-limiting, coalescing and per-user preference-override behaviour unchanged + +#### Scenario: System source/agent health threshold dispatches an operational notification +- GIVEN OpenRegister's `source` system schema declares a `threshold` rule on consecutive failures (or an `updated`+`condition` rule on a health field) +- WHEN the source becomes unhealthy per the configured threshold/condition +- THEN a notification MUST be delivered to the configured integration-ops group +- AND the dispatch MUST reuse the existing threshold/condition evaluation, numeric `calculatedChange` semantics being unchanged diff --git a/openspec/changes/openregister-system-notifications/tasks.md b/openspec/changes/openregister-system-notifications/tasks.md new file mode 100644 index 0000000000..f4b4960736 --- /dev/null +++ b/openspec/changes/openregister-system-notifications/tasks.md @@ -0,0 +1,41 @@ +## 1. Confirm the open question (BLOCKING — answer before implementing) + +- [ ] 1.1 Confirm the dispatch path does NOT currently fire on OpenRegister's own system entities (verified: system entities are plain `OCP\AppFramework\Db\Entity` records, not `ObjectEntity`, and do not flow through `ObjectCreatedEvent`/`ObjectUpdatedEvent`/`ObjectTransitionedEvent`). +- [ ] 1.2 Decide the system-schema rule source shape: (a) synthetic schema-backed system schemas vs (b) a system-schema rule registry the dispatcher consults. Record the decision in design.md. +- [ ] 1.3 Fix the canonical slug/identifier for each system schema (`register`, `schema`, `configuration`, `source`, `synchronization`, `import`, `webhook`, `agent`). +- [ ] 1.4 Identify which system entities already emit create/update/transition signals vs need new event emission (esp. Synchronization/Import run outcomes, Source/Agent health). + +## 2. System-schema rule source + +- [ ] 2.1 Implement the chosen rule source (a or b) so the dispatcher can resolve `x-openregister-notifications` for a system entity through the existing annotation-sourced path — no notification-rule table. +- [ ] 2.2 Declare the recommended rules on the system schemas: synchronization-failed, import-failed, schema-changed, configuration-changed, source-unhealthy, agent-unhealthy (bilingual nl/en, metadata-only subjects). + +## 3. System-event bridge + +- [ ] 3.1 Route create/update/transition signals for the relevant system entities through `AnnotationNotificationListener` → `AnnotationNotificationDispatcher`. +- [ ] 3.2 Populate `_oldData`/`_newData` on the system-entity update dispatch so the `notification-updated-field-change-condition` `condition` block works for system schemas. +- [ ] 3.3 Emit the missing signals where a system entity does not yet fire one (sync/import run outcomes, source/agent health). + +## 4. Recipients, channels, i18n reuse + +- [ ] 4.1 Wire recipients: `{"kind":"groups","groups":["admin"]}` / integration-ops group and schema/config owners (`{"kind":"object-acl","permission":"manage"}` or owner field). +- [ ] 4.2 Confirm the existing channels, rate-limiting, coalescing, per-user preference overrides and nl/en i18n apply unchanged to system-schema dispatches. + +## 5. Tests + +- [ ] 5.1 Unit test: a system synchronization failure dispatches a notification to the admin group via the existing dispatcher path. +- [ ] 5.2 Unit test: a configuration update dispatches an `updated` rule to the admin group. +- [ ] 5.3 Unit test: a source/agent health threshold (or `updated`+condition) dispatches to integration-ops. +- [ ] 5.4 Unit test: stored-object notification behaviour is unchanged (no regression on user-schema rules). + +## Acceptance criteria + +- OpenRegister's system schemas can declare and fire `x-openregister-notifications` rules for operational events through the existing annotation-sourced dispatch path (no notification-rule table). +- The recommended rule set (sync/import failure, schema/config change, source/agent health) is declared with bilingual, metadata-only subjects. +- The field-change `condition` block applies to system-schema `updated` rules (old/new data populated by the bridge). +- Stored-object notification behaviour and numeric `calculatedChange` semantics are unchanged. + +## Quality items + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan) with no new violations. +- New PHPUnit tests pass and existing notification dispatcher/listener tests remain green. diff --git a/openspec/specs/notificatie-engine/spec.md b/openspec/specs/notificatie-engine/spec.md index 52fd276d7d..a78e34d8b6 100644 --- a/openspec/specs/notificatie-engine/spec.md +++ b/openspec/specs/notificatie-engine/spec.md @@ -22,7 +22,7 @@ This spec is an extension of existing infrastructure, not a greenfield build: ## Requirements ### Requirement: The system MUST integrate with Nextcloud's INotificationManager for in-app notifications -All notification delivery to Nextcloud users MUST go through Nextcloud's native `OCP\Notification\IManager` interface. The existing `Notifier` class (implementing `INotifier`) MUST be extended to handle all notification subjects beyond `configuration_update_available`, including object lifecycle events, threshold alerts, and workflow-triggered notifications. +All notification delivery to Nextcloud users MUST go through Nextcloud's native `OCP\Notification\IManager` interface. The object-lifecycle subjects declared by `x-openregister-notifications` — at minimum `object_created`, `object_updated`, and an assignment/transition subject (`object_transitioned`) — MUST be rendered by a registered `INotifier`. In OpenRegister this rendering lives in `AnnotationNotifier` (registered via `registerNotifierService`), which owns those subjects plus anything carrying a pre-rendered `_text` parameter; `Notifier` (registered via `appinfo/info.xml`) continues to own `configuration_update_available`. The two notifiers are mutually exclusive by subject so Nextcloud's sequential `Manager::prepare()` never double-renders. Each object subject MUST be internationalised in Dutch (nl) and English (en) via `IFactory::get('openregister', )` and MUST carry a primary action link to the object detail view. Push delivery is achieved by `notify_push` auto-intercepting the same `IManager` notification — the `push` channel is declared, not coded. #### Scenario: Deliver object creation notification via INotificationManager - GIVEN a notification rule targeting channel `in-app` for schema `meldingen` on event `object.created` @@ -42,18 +42,24 @@ All notification delivery to Nextcloud users MUST go through Nextcloud's native - THEN the system MUST call `IManager::markProcessed()` for all notifications with object type `register_object` and id matching `melding-5` - AND those notifications MUST disappear from the user's notification panel -#### Scenario: Notifier prepares notification with correct i18n -- GIVEN the Notifier receives an `INotification` with subject `object_updated` and `languageCode` = `nl` -- WHEN `Notifier::prepare()` is called +#### Scenario: AnnotationNotifier renders object_created with nl i18n and action link +- GIVEN `AnnotationNotifier` receives an `INotification` with subject `object_created` and `languageCode` = `nl` +- WHEN `prepare()` is called - THEN it MUST use `IFactory::get('openregister', 'nl')` to load Dutch translations -- AND the parsed subject MUST read `Object "%s" bijgewerkt in register "%s"` with the object title and register name substituted -- AND the notification icon MUST be set to the OpenRegister app icon via `IURLGenerator::imagePath()` +- AND the parsed subject MUST read the schema's custom per-locale subject when declared, otherwise the canonical Dutch object-created string with the object title and register name substituted +- AND it MUST add a primary action labelled `Bekijken` linking to the absolute route `openregister.dashboard.page` with fragment `#/registers/{registerId}/schemas/{schemaId}/objects/{objectUuid}`, request type `GET` +- AND the notification icon MUST be set via `IURLGenerator::imagePath('openregister', ...)` -#### Scenario: Notifier adds action link to object detail view -- GIVEN a notification for object UUID `abc-123` in register `5` and schema `12` -- WHEN `Notifier::prepare()` formats the notification -- THEN it MUST add a primary action with label `Bekijken` and link to the absolute route `openregister.dashboard.page` with fragment `#/registers/5/schemas/12/objects/abc-123` -- AND the action request type MUST be `GET` +#### Scenario: AnnotationNotifier renders object_updated with en i18n +- GIVEN `AnnotationNotifier` receives an `INotification` with subject `object_updated` and `languageCode` = `en` +- WHEN `prepare()` is called +- THEN the parsed subject MUST read the English object-updated string with the title and register name substituted +- AND it MUST add a primary action labelled `View` linking to the object detail view + +#### Scenario: AnnotationNotifier declines subjects it does not own +- GIVEN `AnnotationNotifier` receives an `INotification` whose subject it does not own (no `_text` and not a canonical object subject — e.g. `configuration_update_available`) +- WHEN `prepare()` is called +- THEN it MUST raise `UnknownNotificationException` so the manager passes the notification on to `Notifier` untouched ### Requirement: The system MUST support configurable notification rules per schema Administrators MUST be able to define notification rules that specify which events on which schemas trigger notifications, to which recipients, via which channels, using which message template. @@ -242,14 +248,13 @@ Failed notification deliveries MUST be retried with configurable backoff strateg - AND only the failed webhook delivery MUST be retried ### Requirement: Users MUST be able to manage their notification preferences -Users MUST be able to opt in or out of specific notification channels or rules via a personal settings interface, without affecting other users' preferences. +Users MUST be able to turn specific schema-declared notifications on or off (and optionally narrow channels) via a personal settings interface, without affecting other users' preferences. Preferences MUST be stored as override-only values in Nextcloud per-user app config under the `openregister` app (NOT in a `NotificationPreference` or `NotificationSubscription` table). When a user has no override for a `(schema, notification-key)` pair, the schema-declared default applies. -#### Scenario: User disables email notifications for a specific rule -- GIVEN notification rule 7 sends email and in-app notifications to group `behandelaars` +#### Scenario: User disables a specific notification +- GIVEN schema `meldingen` declares an `object_created` notification (default on) to group `behandelaars` - AND user `jan` is a member of `behandelaars` -- WHEN `jan` disables the `email` channel for rule 7 via `PUT /api/notification-preferences` -- THEN `jan` MUST NOT receive email notifications for rule 7 -- AND `jan` MUST still receive in-app notifications for rule 7 +- WHEN `jan` turns off `(meldingen, object_created)` via `PUT /api/notification-preferences` +- THEN `jan` MUST NOT receive that notification - AND other members of `behandelaars` MUST be unaffected #### Scenario: User opts out of all notifications for a schema @@ -271,30 +276,18 @@ Users MUST be able to opt in or out of specific notification channels or rules v - THEN `jan` MUST still receive the notification on all channels including email - AND the notification MUST be visually marked as critical in the notification panel -#### Scenario: Retrieve user notification preferences -- GIVEN user `jan` has customized preferences for 3 rules +#### Scenario: Retrieve effective user notification preferences +- GIVEN user `jan` has customised 2 of the notifications his accessible schemas declare - WHEN `jan` calls `GET /api/notification-preferences` -- THEN the response MUST list all notification rules the user is subscribed to, with per-rule channel settings -- AND rules where the user has no custom preferences MUST show the default channel configuration - -### Requirement: Notifications MUST support per-register and per-schema channel subscriptions -Administrators MUST be able to configure notification channels at the register or schema level, providing default notification behavior that individual rules can override. - -#### Scenario: Register-level default notification channel -- GIVEN register `zaken` is configured with default notification channels `["in-app"]` -- WHEN a notification rule is created for schema `meldingen` in register `zaken` without specifying channels -- THEN the rule MUST inherit the register's default channels (`in-app`) +- THEN the response MUST list every declared notification for his accessible schemas with its effective on/off (and channel) value +- AND for the 2 customised entries the effective value MUST reflect his overrides, with the remainder showing the schema defaults +- AND each entry MUST indicate whether its value came from the schema default or a user override -#### Scenario: Schema-level notification channel override -- GIVEN register `zaken` has default channels `["in-app"]` -- AND schema `vergunningen` overrides with channels `["in-app", "email"]` -- WHEN a notification rule for `vergunningen` inherits defaults -- THEN it MUST use the schema-level override `["in-app", "email"]`, not the register default - -#### Scenario: Rule-level channel takes precedence -- GIVEN schema `meldingen` has default channels `["in-app"]` -- AND a notification rule explicitly sets channels `["webhook"]` -- THEN the rule MUST use only `["webhook"]`, overriding the schema default +#### Scenario: User with no overrides sees all schema defaults +- GIVEN user `piet` has never set any override +- WHEN `piet` calls `GET /api/notification-preferences` +- THEN every entry MUST reflect the schema-declared default +- AND no per-user row or migration MUST be required for the read to succeed ### Requirement: The system MUST support VNG Notificaties API compliance For Dutch government interoperability, the notification engine MUST support publishing notifications in the VNG Notificaties API format, enabling integration with ZGW-compatible systems via the Notificatierouteringscomponent (NRC) pattern. @@ -507,6 +500,87 @@ The system MUST enforce rate limits on notification delivery per recipient, per - AND all subsequent notifications in that hour MUST be queued - AND an admin alert MUST be generated: `Globale notificatielimiet bereikt` +### Requirement: Notification rules MUST be sourced ONLY from the schema annotation, evaluated at dispatch time +The system MUST treat `configuration['x-openregister-notifications']` on the `Schema` as the single, authoritative source of notification rules. Because the schema is ALWAYS loaded whenever anything happens to an object, the dispatcher MUST evaluate the notification rules directly from the already-loaded schema annotation at dispatch time. The system MUST NOT persist notification rules in any separate rule table, and MUST NOT introduce a `NotificationRule` entity or `oc_openregister_notification_rules` table. (ADR-031: `x-openregister-notifications` is the declarative replacement for app-local notification service code.) + +#### Scenario: Dispatcher reads rules from the loaded schema, not a rule table +- **WHEN** an object lifecycle event fires for an object whose schema declares an `x-openregister-notifications` rule on `object.created` +- **THEN** the dispatcher MUST evaluate that rule from the schema annotation already loaded for the object +- **AND** the system MUST NOT query any notification-rule table (none exists) + +#### Scenario: Editing the schema annotation changes dispatch behaviour immediately +- **WHEN** an administrator updates `x-openregister-notifications` on a schema to add a new `object.updated` rule and saves the schema +- **THEN** the next `object.updated` event on that schema MUST be evaluated against the new rule +- **AND** no rule-table row creation, migration, or rebuild step is required for the change to take effect + +### Requirement: User notification preferences MUST be override-only values stored in Nextcloud per-user app config +The system MUST store a user's notification preferences as per-user app-config values under the `openregister` app via `OCP\IConfig::setUserValue`. A stored user value MUST act ONLY as an override that flips the schema-declared default (on/off, and optionally channel) for a single `(schema, notification-key)` pair. The system MUST NOT introduce a `NotificationPreference` table or rely on a `NotificationSubscription` table for preference resolution. + +#### Scenario: Stored override flips the schema default off +- **GIVEN** schema `meldingen` declares notification key `object_created` with default `enabled: true` +- **AND** user `behandelaar-1` has stored an override for `(meldingen, object_created)` of `enabled: false` +- **WHEN** a melding object is created and `behandelaar-1` is a resolved recipient +- **THEN** the system MUST NOT deliver the in-app/push notification to `behandelaar-1` + +#### Scenario: Preferences are isolated per user +- **GIVEN** user `behandelaar-1` has an override turning `(meldingen, object_created)` off +- **WHEN** the override is read for user `behandelaar-2` +- **THEN** the system MUST return the schema default for `behandelaar-2` (no value), unaffected by `behandelaar-1`'s override + +### Requirement: Unknown (schema, notification-key) pairs MUST fall through to the schema default with zero migration +The preference-resolution layer MUST be tolerant of unknown keys: adding a NEW schema, or adding a NEW notification to an EXISTING schema, MUST keep working without any migration, data backfill, or rebuild. Any `(schema, notification-key)` pair for which no user override exists MUST resolve to the schema-declared default. The system MUST NOT require a per-user row, table, or migration to exist before a notification can be delivered. + +#### Scenario: New schema works immediately with no migration +- **GIVEN** a brand-new schema `klachten` is saved with an `x-openregister-notifications` `object_created` default of `enabled: true` +- **AND** no user has any stored override for any `klachten` notification +- **WHEN** a `klachten` object is created +- **THEN** the system MUST deliver the notification to resolved recipients using the schema default +- **AND** no migration or preference-table backfill MUST be required + +#### Scenario: New notification added to an existing schema falls through +- **GIVEN** schema `meldingen` already had `object_created` notifications and users had overrides for it +- **WHEN** the schema is updated to ALSO declare an `object_updated` notification with default `enabled: true` +- **AND** users have no override for `(meldingen, object_updated)` +- **THEN** an `object_updated` event MUST deliver to recipients using the new schema default +- **AND** existing `(meldingen, object_created)` overrides MUST remain unaffected + +### Requirement: The effective-preferences API MUST expose schema-default-merged-with-override reads and single-pair override writes +The system MUST expose an API for the per-user settings pane: a GET endpoint returning the EFFECTIVE notifications for the current user (every notification the user's accessible schemas declare, merged with that user's stored overrides), and a PUT endpoint that records or clears a single `(schema, notification-key)` override. The API MUST be authenticated as the current Nextcloud user and MUST only read/write that user's own overrides. + +#### Scenario: GET returns merged effective preferences +- **GIVEN** schema `meldingen` declares `object_created` (default on) and `object_updated` (default off) +- **AND** the current user has an override turning `object_created` off +- **WHEN** the user calls the effective-preferences GET endpoint +- **THEN** the response MUST list `(meldingen, object_created)` as effectively `off` (from the override) and `(meldingen, object_updated)` as effectively `off` (from the schema default) +- **AND** MUST indicate, per entry, whether the effective value came from the schema default or a user override + +#### Scenario: PUT clearing an override restores the schema default +- **GIVEN** the current user has an override for `(meldingen, object_created)` +- **WHEN** the user calls the override PUT endpoint to clear/reset that pair +- **THEN** the stored user-config value MUST be removed +- **AND** a subsequent effective-preferences GET MUST show the schema default for that pair + +#### Scenario: A user cannot read or write another user's overrides +- **WHEN** an authenticated user calls the effective-preferences GET or override PUT endpoint +- **THEN** the system MUST scope all reads and writes to the authenticated user's own app-config values + +### Requirement: The dispatcher MUST consult the merged preference before delivering the in-app/push channel +Before delivering the in-app (`nc-notification`) or `push` channel to a given recipient, the dispatcher MUST resolve the effective preference for that recipient as `schema-default ⊕ user-override` and MUST skip the recipient when the effective preference is off. The dispatcher MUST evaluate this per recipient so that one recipient's override never affects another's delivery. + +#### Scenario: Recipient with an off override is skipped +- **GIVEN** a schema rule resolves recipients `jan` and `piet` for an `object.created` in-app notification +- **AND** `jan` has an override turning that notification off +- **WHEN** the dispatcher fans out the in-app channel +- **THEN** `jan` MUST NOT receive the notification +- **AND** `piet` MUST receive it (no override → schema default applies) + +#### Scenario: Channel override narrows delivery when supported +- **GIVEN** a schema rule declares both `nc-notification` and `push` channels for a recipient +- **AND** the recipient's override specifies the notification is on but only for the in-app channel +- **WHEN** the dispatcher fans out +- **THEN** the in-app notification MUST be delivered and the push channel MUST be suppressed for that recipient +- **AND** if the override specifies on/off only (no channel), both declared channels MUST follow the on/off value + ## Current Implementation Status - **Partially implemented -- in-app notifications**: `NotificationService` (`lib/Service/NotificationService.php`) exists and integrates with Nextcloud's `IManager` (INotificationManager). Currently limited to `configuration_update_available` notifications. `Notifier` (`lib/Notification/Notifier.php`) implements `INotifier` for formatting notifications with translations. Registered as a notifier service in `appinfo/info.xml`. - **Partially implemented -- webhook notifications**: `WebhookService` (`lib/Service/WebhookService.php`) handles outbound webhook delivery with HMAC signing, event filtering, and payload mapping. `WebhookEventListener` (`lib/Listener/WebhookEventListener.php`) listens for 55+ object/register/schema/configuration lifecycle events and triggers webhooks. Webhook entities stored via `WebhookMapper` with `organisation` field for multi-tenant scoping. Delivery logged in `WebhookLog`/`WebhookLogMapper`. @@ -559,4 +633,4 @@ The system MUST enforce rate limits on notification delivery per recipient, per **Nextcloud Core Integration**: The notification engine is natively integrated with Nextcloud's `INotifier` interface (registered during app bootstrap via `appinfo/info.xml` service declaration). This means OpenRegister notifications appear in the standard Nextcloud notification bell. The `notify_push` app (if installed) automatically intercepts `INotificationManager::notify()` calls and pushes them to connected clients via WebSocket, giving OpenRegister real-time push notifications without any additional code. Email delivery via Nextcloud's built-in notification-to-email feature is available when users configure email delivery in their Nextcloud notification settings. The Notifier handles i18n through Nextcloud's `IL10N` translation system via `IFactory::get()`. Webhook delivery runs asynchronously via Nextcloud's `QueuedJob` background job system, ensuring notification processing does not block the originating request. The `INotificationManager` handles the full notification lifecycle: create, mark processed, and dismiss. -**Recommendation**: The in-app notification integration via `INotifier` is the correct and native approach for Nextcloud. Extend the existing `Notifier::prepare()` to handle additional subjects (`object_created`, `object_updated`, `object_deleted`, `threshold_alert`, `workflow_completed`, `digest`) beyond the current `configuration_update_available`. For email notifications, the recommended path is to delegate to n8n workflows via the existing webhook system rather than implementing direct SMTP, which aligns with the project direction. For push notifications, rely on Nextcloud's `notify_push` automatic interception of `INotificationManager::notify()` calls. New entities needed: `NotificationRule` (configurable rules), `NotificationPreference` (per-user opt-in/out), and optionally `NotificationHistory` (audit trail). The existing `WebhookService` and `WebhookEventListener` provide a solid foundation for the webhook channel; the notification engine should build on top of them rather than replacing them. +**Recommendation**: The in-app notification integration via `INotifier` is the correct and native approach for Nextcloud. Object-lifecycle subjects (`object_created`, `object_updated`, `object_transitioned`) are rendered by `AnnotationNotifier`, which owns those subjects and anything carrying a pre-rendered `_text`; `Notifier` keeps `configuration_update_available` (the two are mutually exclusive by subject). For email notifications, the recommended path is to delegate to n8n workflows via the existing webhook system rather than implementing direct SMTP, which aligns with the project direction. For push notifications, rely on Nextcloud's `notify_push` automatic interception of `INotificationManager::notify()` calls. **No `NotificationRule` or `NotificationPreference`/`NotificationSubscription` tables are introduced**: notification rules live ONLY in the schema annotation `x-openregister-notifications` (evaluated at dispatch from the always-loaded schema), and per-user preferences are override-only Nextcloud user-config values that flip the schema default for a `(schema, notification-key)` pair (absence falls through to the default — zero migration). `NotificationHistory` remains for the audit trail. The existing `WebhookService` and `WebhookEventListener` provide a solid foundation for the webhook channel; the notification engine builds on top of them rather than replacing them. diff --git a/tests/Unit/Controller/NotificationPreferencesControllerTest.php b/tests/Unit/Controller/NotificationPreferencesControllerTest.php new file mode 100644 index 0000000000..087d8d4a63 --- /dev/null +++ b/tests/Unit/Controller/NotificationPreferencesControllerTest.php @@ -0,0 +1,120 @@ +request = $this->createMock(IRequest::class); + $this->preferenceService = $this->createMock(NotificationPreferenceService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->controller = new NotificationPreferencesController( + 'openregister', + $this->request, + $this->preferenceService, + $this->userSession + ); + } + + private function signIn(string $uid): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + } + + public function testIndexReturnsEffectivePreferences(): void + { + $this->signIn('jan'); + $entries = [ + ['schema' => 'meldingen', 'notification' => 'object_created', 'enabled' => true, 'source' => 'schema-default'], + ]; + $this->preferenceService->expects($this->once()) + ->method('getEffectiveForUser') + ->with('jan') + ->willReturn($entries); + + $response = $this->controller->index(); + $data = $response->getData(); + + $this->assertSame($entries, $data['results']); + $this->assertSame(1, $data['total']); + } + + public function testIndexRequiresAuthentication(): void + { + $this->userSession->method('getUser')->willReturn(null); + + $response = $this->controller->index(); + $this->assertSame(401, $response->getStatus()); + } + + public function testUpdateWritesOverride(): void + { + $this->signIn('jan'); + $this->request->method('getParams')->willReturn([ + 'schema' => 'meldingen', + 'notification' => 'object_created', + 'enabled' => false, + ]); + + $this->preferenceService->expects($this->once()) + ->method('setOverride') + ->with('jan', 'meldingen', 'object_created', ['enabled' => false]); + $this->preferenceService->method('getOverride')->willReturn(['enabled' => false]); + + $response = $this->controller->update(); + $data = $response->getData(); + + $this->assertSame('meldingen', $data['schema']); + $this->assertSame(['enabled' => false], $data['override']); + } + + public function testUpdateClearsOverrideOnReset(): void + { + $this->signIn('jan'); + $this->request->method('getParams')->willReturn([ + 'schema' => 'meldingen', + 'notification' => 'object_created', + 'reset' => true, + ]); + + $this->preferenceService->expects($this->once()) + ->method('setOverride') + ->with('jan', 'meldingen', 'object_created', null); + + $response = $this->controller->update(); + $this->assertNull($response->getData()['override']); + } + + public function testUpdateRejectsMissingFields(): void + { + $this->signIn('jan'); + $this->request->method('getParams')->willReturn(['schema' => 'meldingen']); + $this->preferenceService->expects($this->never())->method('setOverride'); + + $response = $this->controller->update(); + $this->assertSame(422, $response->getStatus()); + } +} diff --git a/tests/Unit/Notification/AnnotationNotifierTest.php b/tests/Unit/Notification/AnnotationNotifierTest.php new file mode 100644 index 0000000000..02ce992667 --- /dev/null +++ b/tests/Unit/Notification/AnnotationNotifierTest.php @@ -0,0 +1,135 @@ +factory = $this->createMock(IFactory::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->notifier = new AnnotationNotifier($this->factory, $this->urlGenerator); + } + + public function testGetId(): void + { + $this->assertSame('openregister', $this->notifier->getID()); + } + + public function testThrowsForWrongApp(): void + { + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('other_app'); + + $this->expectException(UnknownNotificationException::class); + $this->notifier->prepare($notification, 'en'); + } + + public function testDeclinesUnownedSubjectWithoutText(): void + { + // configuration_update_available is rendered by Notifier, not here — + // with no _text and a non-object subject this notifier must decline + // so the manager passes it on untouched. + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('openregister'); + $notification->method('getSubject')->willReturn('configuration_update_available'); + $notification->method('getSubjectParameters')->willReturn([]); + + $this->expectException(UnknownNotificationException::class); + $this->notifier->prepare($notification, 'en'); + } + + public function testRendersCustomTextWhenPresent(): void + { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $this->factory->method('get')->willReturn($l10n); + $this->urlGenerator->method('imagePath')->willReturn('/apps/openregister/img/app.svg'); + $this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/apps/openregister'); + + $action = $this->createMock(IAction::class); + $action->method('setLabel')->willReturnSelf(); + $action->method('setPrimary')->willReturnSelf(); + $action->method('setLink')->willReturnSelf(); + + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('openregister'); + $notification->method('getSubject')->willReturn('object_created'); + $notification->method('getSubjectParameters')->willReturn([ + '_text' => 'Nieuwe lead: Acme', + 'registerId' => 'r', + 'schemaId' => 's', + 'objectUuid' => 'uuid-1', + ]); + $notification->method('createAction')->willReturn($action); + $notification->method('setIcon')->willReturnSelf(); + $notification->method('addAction')->willReturnSelf(); + + // Custom _text is shown verbatim; a deep-link action is added. + $notification->expects($this->once())->method('setParsedSubject')->with('Nieuwe lead: Acme')->willReturnSelf(); + $notification->expects($this->once())->method('addAction'); + + $result = $this->notifier->prepare($notification, 'nl'); + $this->assertSame($notification, $result); + } + + public function testRendersCanonicalLocalizedWhenNoText(): void + { + $l10n = $this->createMock(IL10N::class); + // Echo back the interpolated template so we can assert substitution. + $l10n->method('t')->willReturnCallback( + static fn(string $text, array $args = []): string => vsprintf($text, $args) + ); + $this->factory->method('get')->with('openregister', 'en')->willReturn($l10n); + $this->urlGenerator->method('imagePath')->willReturn('/apps/openregister/img/app.svg'); + $this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/apps/openregister'); + + $action = $this->createMock(IAction::class); + $action->method('setLabel')->willReturnSelf(); + $action->method('setPrimary')->willReturnSelf(); + $action->method('setLink')->willReturnSelf(); + + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('openregister'); + $notification->method('getSubject')->willReturn('object_created'); + $notification->method('getSubjectParameters')->willReturn([ + 'objectTitle' => 'Acme', + 'registerName' => 'Leads', + 'registerId' => 'r', + 'schemaId' => 's', + 'objectUuid' => 'uuid-1', + ]); + $notification->method('createAction')->willReturn($action); + $notification->method('setIcon')->willReturnSelf(); + $notification->method('addAction')->willReturnSelf(); + + $notification->expects($this->once()) + ->method('setParsedSubject') + ->with('Object "Acme" created in register "Leads"') + ->willReturnSelf(); + + $this->notifier->prepare($notification, 'en'); + } +} diff --git a/tests/Unit/Repair/MigrateNotificationSubscriptionsToUserConfigTest.php b/tests/Unit/Repair/MigrateNotificationSubscriptionsToUserConfigTest.php new file mode 100644 index 0000000000..e7a1fe9c04 --- /dev/null +++ b/tests/Unit/Repair/MigrateNotificationSubscriptionsToUserConfigTest.php @@ -0,0 +1,63 @@ +container = $this->createMock(ContainerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->step = new MigrateNotificationSubscriptionsToUserConfig( + $this->container, + $this->logger + ); + } + + public function testGetNameIsHumanReadable(): void + { + $this->assertStringContainsString('notification subscriptions', $this->step->getName()); + } + + public function testRunSkipsGracefullyWhenDbUnavailable(): void + { + // Container cannot resolve the DB connection (fresh install) — the + // step must log "skipped" and return without throwing. + $this->container->method('get')->willThrowException(new \RuntimeException('no service')); + + $output = $this->createMock(IOutput::class); + $output->expects($this->atLeastOnce())->method('info'); + + $this->step->run($output); + $this->addToAssertionCount(1); + } + + public function testRunDoesNotThrowOnInfrastructureErrors(): void + { + // Any throwable from the container must be swallowed. + $this->container->method('get')->willThrowException(new \LogicException('boom')); + + $output = $this->createMock(IOutput::class); + + $this->step->run($output); + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php b/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php index 6cb64b2857..ad0e5a4fdb 100644 --- a/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php +++ b/tests/Unit/Service/Notification/AnnotationNotificationDispatcherTest.php @@ -9,6 +9,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\Notification\AnnotationNotificationDispatcher; +use OCA\OpenRegister\Service\Notification\NotificationPreferenceService; use OCA\OpenRegister\Service\Notification\RecipientResolverInterface; use OCP\Activity\IEvent as IActivityEvent; use OCP\Activity\IManager as IActivityManager; @@ -813,6 +814,154 @@ public function testFieldRecipientDroppedWhenUidDoesNotExist(): void $dispatcher->dispatch($object, 'updated'); }//end testFieldRecipientDroppedWhenUidDoesNotExist() + public function testPreferenceGateSkipsRecipientWithOffOverride(): void + { + // Per-recipient merged-preference gate: jan turned the notification + // off, piet has no override → only piet receives it. Proves overrides + // are isolated per recipient and fall through to the schema default. + $schema = $this->schemaWithNotification( + [ + 'r1' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'users', 'users' => ['jan', 'piet']]], + 'subject' => 'demo', + 'enabled' => true, + ], + ] + ); + $this->schemaMapper->method('find')->willReturn($schema); + + // IConfig: jan has an "off" override for (s, r1); piet has none. + $config = $this->createMock(IConfig::class); + $config->method('getSystemValue')->willReturnCallback( + static fn(string $key, mixed $default = null): mixed => $default ?? 'http://localhost' + ); + $config->method('getUserValue')->willReturnCallback( + static function (string $uid, string $app, string $key, mixed $default = '') { + if ($uid === 'jan' && $key === 'notification_pref/s/r1') { + return '{"enabled":false}'; + } + return $default; + } + ); + + $prefs = new NotificationPreferenceService($config, $this->schemaMapper, $this->logger); + + $delivered = []; + $this->expectNotificationManagerCalls($delivered); + + $dispatcher = $this->makeDispatcher(config: $config, preferenceService: $prefs); + $dispatcher->dispatch($this->object($schema), 'updated'); + + $this->assertSame(['piet'], $delivered); + }//end testPreferenceGateSkipsRecipientWithOffOverride() + + /** + * Build an `updated` rule carrying a field-change `condition`. + * + * @param array $condition The condition sub-document. + * + * @return Schema + */ + private function schemaWithUpdatedCondition(array $condition): Schema + { + return $this->schemaWithNotification( + [ + 'statusRule' => [ + 'trigger' => ['type' => 'updated', 'condition' => $condition], + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'users', 'users' => ['admin']]], + 'subject' => 'changed', + ], + ] + ); + }//end schemaWithUpdatedCondition() + + public function testUpdatedChangedFiresWhenValueDiffers(): void + { + $schema = $this->schemaWithUpdatedCondition(['field' => 'status', 'operator' => 'changed']); + $this->schemaMapper->method('find')->willReturn($schema); + $this->notificationManager->expects($this->once())->method('notify'); + + $this->makeDispatcher()->dispatch( + $this->object($schema), + 'updated', + ['_oldData' => ['status' => 'open'], '_newData' => ['status' => 'closed']] + ); + }//end testUpdatedChangedFiresWhenValueDiffers() + + public function testUpdatedChangedSkipsWhenValueUnchanged(): void + { + $schema = $this->schemaWithUpdatedCondition(['field' => 'status', 'operator' => 'changed']); + $this->schemaMapper->method('find')->willReturn($schema); + $this->notificationManager->expects($this->never())->method('createNotification'); + + $this->makeDispatcher()->dispatch( + $this->object($schema), + 'updated', + ['_oldData' => ['status' => 'open'], '_newData' => ['status' => 'open']] + ); + }//end testUpdatedChangedSkipsWhenValueUnchanged() + + public function testUpdatedEqualsFiresOnTargetValue(): void + { + $schema = $this->schemaWithUpdatedCondition(['field' => 'status', 'operator' => 'equals', 'value' => 'afgehandeld']); + $this->schemaMapper->method('find')->willReturn($schema); + $this->notificationManager->expects($this->once())->method('notify'); + + $this->makeDispatcher()->dispatch( + $this->object($schema), + 'updated', + ['_oldData' => ['status' => 'open'], '_newData' => ['status' => 'afgehandeld']] + ); + }//end testUpdatedEqualsFiresOnTargetValue() + + public function testUpdatedEqualsWithFromRequiresPriorValue(): void + { + $schema = $this->schemaWithUpdatedCondition( + ['field' => 'status', 'operator' => 'equals', 'value' => 'afgehandeld', 'from' => 'in_behandeling'] + ); + $this->schemaMapper->method('find')->willReturn($schema); + // Prior value is 'open', not the required 'in_behandeling' → no fire. + $this->notificationManager->expects($this->never())->method('createNotification'); + + $this->makeDispatcher()->dispatch( + $this->object($schema), + 'updated', + ['_oldData' => ['status' => 'open'], '_newData' => ['status' => 'afgehandeld']] + ); + }//end testUpdatedEqualsWithFromRequiresPriorValue() + + public function testUpdatedConditionFailsClosedWithoutOldNewData(): void + { + $schema = $this->schemaWithUpdatedCondition(['field' => 'status', 'operator' => 'changed']); + $this->schemaMapper->method('find')->willReturn($schema); + // No _oldData/_newData in context → condition cannot be evaluated → no fire. + $this->notificationManager->expects($this->never())->method('createNotification'); + + $this->makeDispatcher()->dispatch($this->object($schema), 'updated'); + }//end testUpdatedConditionFailsClosedWithoutOldNewData() + + public function testConditionlessUpdatedStillFires(): void + { + // Back-compat: an `updated` rule with no condition fires on every update. + $schema = $this->schemaWithNotification( + [ + 'anyUpdate' => [ + 'trigger' => ['type' => 'updated'], + 'channels' => ['nc-notification'], + 'recipients' => [['kind' => 'users', 'users' => ['admin']]], + 'subject' => 'any', + ], + ] + ); + $this->schemaMapper->method('find')->willReturn($schema); + $this->notificationManager->expects($this->once())->method('notify'); + + $this->makeDispatcher()->dispatch($this->object($schema), 'updated'); + }//end testConditionlessUpdatedStillFires() + /** * @param array $notifications */ @@ -837,7 +986,8 @@ private function object(Schema $schema): ObjectEntity private function makeDispatcher( ?IConfig $config=null, - ?NotificationDispatchLogMapper $dispatchLogMapper=null + ?NotificationDispatchLogMapper $dispatchLogMapper=null, + ?NotificationPreferenceService $preferenceService=null ): AnnotationNotificationDispatcher { return new AnnotationNotificationDispatcher( $this->schemaMapper, @@ -854,7 +1004,8 @@ private function makeDispatcher( null, null, null, - $dispatchLogMapper + $dispatchLogMapper, + $preferenceService ); }//end makeDispatcher() diff --git a/tests/Unit/Service/Notification/NotificationPreferenceServiceTest.php b/tests/Unit/Service/Notification/NotificationPreferenceServiceTest.php new file mode 100644 index 0000000000..2482d26c11 --- /dev/null +++ b/tests/Unit/Service/Notification/NotificationPreferenceServiceTest.php @@ -0,0 +1,172 @@ +config = $this->createMock(IConfig::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->service = new NotificationPreferenceService( + $this->config, + $this->schemaMapper, + $this->logger + ); + } + + public function testConfigKeyShape(): void + { + $this->assertSame( + 'notification_pref/meldingen/object_created', + $this->service->configKey('meldingen', 'object_created') + ); + } + + public function testFallThroughToSchemaDefaultWhenNoOverride(): void + { + // No stored value → resolve to the schema default, tagged schema-default. + $this->config->method('getUserValue')->willReturn(''); + + $effective = $this->service->resolveEffective( + ['enabled' => true, 'channels' => ['nc-notification']], + 'piet', + 'meldingen', + 'object_created' + ); + + $this->assertTrue($effective['enabled']); + $this->assertSame('schema-default', $effective['source']); + } + + public function testOverrideFlipsSchemaDefaultOff(): void + { + $this->config->method('getUserValue')->willReturn('{"enabled":false}'); + + $effective = $this->service->resolveEffective( + ['enabled' => true], + 'jan', + 'meldingen', + 'object_created' + ); + + $this->assertFalse($effective['enabled']); + $this->assertSame('user-override', $effective['source']); + } + + public function testPerUserIsolation(): void + { + // jan has an override; piet has none — the same pair resolves + // independently per user. + $this->config->method('getUserValue')->willReturnCallback( + static function (string $uid, string $app, string $key, mixed $default = '') { + return $uid === 'jan' ? '{"enabled":false}' : $default; + } + ); + + $jan = $this->service->resolveEffective(['enabled' => true], 'jan', 'meldingen', 'object_created'); + $piet = $this->service->resolveEffective(['enabled' => true], 'piet', 'meldingen', 'object_created'); + + $this->assertFalse($jan['enabled']); + $this->assertTrue($piet['enabled']); + } + + public function testChannelNarrowingRestrictsToSubset(): void + { + // Override narrows the two declared channels down to in-app only. + $this->config->method('getUserValue')->willReturn('{"enabled":true,"channels":["nc-notification"]}'); + + $effective = $this->service->resolveEffective( + ['enabled' => true, 'channels' => ['nc-notification', 'push']], + 'jan', + 'meldingen', + 'object_created' + ); + + $this->assertTrue($effective['enabled']); + $this->assertSame(['nc-notification'], $effective['channels']); + } + + public function testSetOverrideClearsWhenNull(): void + { + $this->config->expects($this->once()) + ->method('deleteUserValue') + ->with('jan', 'openregister', 'notification_pref/meldingen/object_created'); + $this->config->expects($this->never())->method('setUserValue'); + + $this->service->setOverride('jan', 'meldingen', 'object_created', null); + } + + public function testSetOverrideWritesJson(): void + { + $this->config->expects($this->once()) + ->method('setUserValue') + ->with( + 'jan', + 'openregister', + 'notification_pref/meldingen/object_created', + $this->callback(static function (string $json): bool { + $decoded = json_decode($json, true); + return is_array($decoded) && ($decoded['enabled'] ?? null) === false; + }) + ); + + $this->service->setOverride('jan', 'meldingen', 'object_created', ['enabled' => false]); + } + + public function testGetEffectiveForUserMergesDeclaredNotifications(): void + { + $schema = new Schema(); + $schema->setId(1); + $schema->setSlug('meldingen'); + $schema->setTitle('Meldingen'); + $schema->setConfiguration([ + 'x-openregister-notifications' => [ + 'object_created' => ['enabled' => true, 'channels' => ['nc-notification']], + 'object_updated' => ['enabled' => false], + ], + ]); + + $this->schemaMapper->method('findAll')->willReturn([$schema]); + // jan overrode object_created off; object_updated falls through. + $this->config->method('getUserValue')->willReturnCallback( + static function (string $uid, string $app, string $key, mixed $default = '') { + return $key === 'notification_pref/meldingen/object_created' ? '{"enabled":false}' : $default; + } + ); + + $entries = $this->service->getEffectiveForUser('jan'); + + $this->assertCount(2, $entries); + $byKey = []; + foreach ($entries as $e) { + $byKey[$e['notification']] = $e; + } + + $this->assertFalse($byKey['object_created']['enabled']); + $this->assertSame('user-override', $byKey['object_created']['source']); + $this->assertFalse($byKey['object_updated']['enabled']); + $this->assertSame('schema-default', $byKey['object_updated']['source']); + } +}