Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<schema>/<notification>`) 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: <filename> ---` 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-<n>` (1-indexed). Per ADR-005, parser-failure log lines are PII-sanitised — addresses, quoted strings, and angle-bracketed values are replaced with `<redacted>` before logging. New dependency: `zbateson/mail-mime-parser:^3.0`. (`text-extraction-eml`)

### Behaviour changes
Expand Down
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Vrij en open source onder de EUPL-licentie.
<post-migration>
<step>OCA\OpenRegister\Repair\RegisterRiskLevelMetadata</step>
<step>OCA\OpenRegister\Repair\LogDanglingLinkedTypes</step>
<step>OCA\OpenRegister\Repair\MigrateNotificationSubscriptionsToUserConfig</step>
</post-migration>
<install>
<step>OCA\OpenRegister\Repair\RegisterRiskLevelMetadata</step>
Expand Down
6 changes: 5 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
17 changes: 17 additions & 0 deletions docs/features/webhooks-and-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<schema>/<notification>`. 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.
Expand Down
4 changes: 4 additions & 0 deletions l10n/nl.js
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
4 changes: 4 additions & 0 deletions l10n/nl.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
181 changes: 181 additions & 0 deletions lib/Controller/NotificationPreferencesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

/**
* NotificationPreferencesController.
*
* REST surface for the per-user, override-only notification preferences
* consumed by the user-settings preferences pane. Rules and their on/off
* defaults live in the schema annotation `x-openregister-notifications`;
* these endpoints only read the EFFECTIVE merge and write/clear a single
* `(schema, notification)` override in Nextcloud per-user app config.
*
* GET /api/notification-preferences
* → every notification the current user's accessible schemas declare,
* merged with that user's overrides, tagged by source.
*
* PUT /api/notification-preferences
* body: { schema, notification, enabled?, channels?, reset? }
* → record (or, with `reset: true`, clear) one override for the
* current user only.
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Controller
* @package OCA\OpenRegister\Controller
*
* @author Conduction Development Team <dev@conduction.nl>
* @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
12 changes: 12 additions & 0 deletions lib/Controller/NotificationSubscriptionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/**
Expand Down
4 changes: 4 additions & 0 deletions lib/Db/NotificationSubscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
5 changes: 5 additions & 0 deletions lib/Db/NotificationSubscriptionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
* @spec openspec/changes/notificatie-engine/tasks.md "Users MUST be able to manage their notification preferences"
*
* @template-extends QBMapper<NotificationSubscription>
*
* @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);
Expand Down
14 changes: 13 additions & 1 deletion lib/Listener/AnnotationNotificationListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading