Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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 @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

### Added
- Navigation target parameters support: event fields annotated with the `key` keyword are sent as `TargetParameters` to ANS, enabling notifications to navigate directly to a specific record in the target application
- Optional DB storage for sent notifications via `cds.notifications.storeNotifications: true`. When enabled, each notification is stored to the database after being sent, including properties and navigation target parameters.

### Fixed
- Fall back to `Locale.ROOT` (`i18n.properties`) when no explicit `i18n_en.properties` exists, so applications following the CAP default i18n convention no longer fail at startup with unresolved English placeholders
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ In **local mode**, notifications are logged to the console with no ANS binding r
- [Case 4: Array of Structured Recipients](#case-4-array-of-structured-recipients)
- [Dynamic Priority](#dynamic-priority)
- [Navigation Target Parameters](#navigation-target-parameters)
- [Storing Notifications to DB](#storing-notifications-to-db)
- [Batch Notifications](#batch-notifications)
- [Identity Authentication Destination (Language Resolution)](#identity-authentication-destination-language-resolution)
- [Step 1: Create a Technical User in Identity Authentication](#step-1-create-a-technical-user-in-identity-authentication)
Expand Down Expand Up @@ -539,6 +540,17 @@ OAuth2 Client Credentials Flow is also supported (uses `ClientId`, `ClientSecret

## Deep Dive

This section covers additional features and configuration options:

- [Recipient Formats](#recipient-formats) — supported recipient types (email, UUID, structured)
- [Dynamic Priority](#dynamic-priority) — CDS expressions for runtime priority evaluation
- [Navigation Target Parameters](#navigation-target-parameters) — `key`-annotated fields for deep linking
- [Storing Notifications to DB](#storing-notifications-to-db) — optional DB persistence for further processing
- [Batch Notifications](#batch-notifications) — emit multiple notifications in a single call
- [Identity Authentication Destination](#identity-authentication-destination-language-resolution) — language resolution via IAS
- [Template Customization](#template-customization) — allow end-users to customize notification content
- [Outbox](#outbox) — guaranteed ordered delivery with automatic retry

### Recipient Formats

The plugin supports four recipient formats:
Expand Down Expand Up @@ -676,6 +688,28 @@ data.setBuyer(buyer);

Clicking the notification in SAP Build Work Zone then opens the specific `Books(bookId)` record rather than the Books list.

### Storing Notifications to DB

By default, the plugin does not persist sent notifications. If your application needs to store sent notifications for further processing, for example to support a cooldown mechanism or keep a history of sent notifications, you can enable DB storage:

```yaml
cds:
notifications:
storeNotifications: true
```

When enabled, the plugin stores each sent notification to the database after it has been processed. The following entities are created automatically in your application's database:

| Entity | Description |
|---|---|
| `sap.cds.notifications.Notifications` | One row per notification per recipient. Stores `ID`, `recipient`, `notificationTypeKey`, `notificationTemplateKey`, `priority`, `navigationTargetObject`, `navigationTargetAction`, and `sentAt` (UTC). Key: `(ID, recipient)` where `ID` is the notification ID (ANS-assigned in production mode, locally generated in local mode). |
| `sap.cds.notifications.NotificationProperties` | Template placeholder values. Stores `propertyKey` and `propertyValue` for each event field not annotated with the `key` keyword. |
| `sap.cds.notifications.NotificationTargetParameters` | Navigation target parameters. Stores `paramKey` and `paramValue` for each event field annotated with the `key` keyword. See [Navigation Target Parameters](#navigation-target-parameters). |

In production mode, the `ID` stored is the one returned by ANS. In local mode, a UUID is generated locally. The `recipient` field holds the email address or UUID of the recipient. Since a single notification can be sent to multiple recipients, one row is created per recipient.

> **Note:** The stored notification entities include `@PersonalData` annotations. This allows the `cap-js/data-privacy` and `cds-feature-data-privacy` modules to automatically handle personal data erasure requests. When a user requests deletion of their data, all notification records for that recipient are removed.

### Batch Notifications

You can emit multiple notifications of the same type in a single call. Instead of emitting each notification separately:
Expand Down
2 changes: 1 addition & 1 deletion cds-feature-notifications/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@
<phase>generate-sources</phase>
<configuration>
<commands>
<command>compile ${project.basedir}/srv/external/*.cds --to csn --dest ${project.build.directory}/cds-output/all.csn</command>
<command>compile ${project.basedir}/srv/external/*.cds ${project.basedir}/src/main/resources/cds/com.sap.cds/cds-feature-notifications/NotificationStorage.cds --to csn --dest ${project.build.directory}/cds-output/all.csn</command>
</commands>
</configuration>
</execution>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@
import com.sap.cds.notifications.handlers.NotificationTemplateAutoProvisionerHandler;
import com.sap.cds.notifications.handlers.NotificationTypeAutoProvisionerHandler;
import com.sap.cds.notifications.handlers.ProductionHandler;
import com.sap.cds.notifications.handlers.StoreNotificationsHandler;
import com.sap.cds.notifications.handlers.StoreNotificationsLocalHandler;
import com.sap.cds.notifications.helpers.NotificationStorageHelper;
import com.sap.cds.services.environment.CdsProperties;
import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig;
import com.sap.cds.services.outbox.OutboxService;
import com.sap.cds.services.persistence.PersistenceService;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.runtime.CdsRuntimeConfiguration;
import com.sap.cds.services.runtime.CdsRuntimeConfigurer;
Expand Down Expand Up @@ -136,6 +140,27 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
// Entity-level @notifications handler, emits CDS events handled by
// ProductionHandler/LocalHandler
configurer.eventHandler(new EntityNotificationHandler());

boolean storeNotifications =
configurer
.getCdsRuntime()
.getEnvironment()
.getProperty("cds.notifications.storeNotifications", Boolean.class, false);

if (storeNotifications) {
PersistenceService db =
configurer
.getCdsRuntime()
.getServiceCatalog()
.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME);
NotificationStorageHelper storageService = new NotificationStorageHelper(db);
if (productionEnabled || ansBindingPresent) {
configurer.eventHandler(new StoreNotificationsHandler(storageService));
} else {
configurer.eventHandler(new StoreNotificationsLocalHandler(storageService));
}
logger.info("storeNotifications enabled - notifications will be stored to DB");
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.runtime.CdsRuntime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -25,6 +27,7 @@
public class LocalHandler implements EventHandler {

private static final Logger logger = LoggerFactory.getLogger(LocalHandler.class);
static final String SENT_NOTIFICATIONS_KEY = "com.sap.cds.notifications.stored";
private final NotificationAssembler notificationBuilder;
private final I18nHelper i18nHelper;

Expand Down Expand Up @@ -118,6 +121,16 @@ public void postNotifications(EventContext context) {
logger.info("└──────────────────────────────────────────────────────────────┘");
}

List<Notifications> sentNotifications = new ArrayList<>();
for (NotificationAssembler.NotificationBuildResult result : results) {
Notifications notification = result.notification();
if (notification.getId() == null) {
notification.setId(UUID.randomUUID().toString());
}
sentNotifications.add(notification);
}
context.put(SENT_NOTIFICATIONS_KEY, sentNotifications);

context.setCompleted();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors.
*/
package com.sap.cds.notifications.handlers;

import cds.gen.notificationproviderservice.NotificationProviderService_;
import cds.gen.notificationproviderservice.Notifications;
import com.sap.cds.notifications.helpers.NotificationStorageHelper;
import com.sap.cds.services.Service;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.After;
import com.sap.cds.services.handler.annotations.ServiceName;
import java.time.Instant;
import java.util.List;

@ServiceName(value = NotificationProviderService_.CDS_NAME, type = Service.class)
public class StoreNotificationsHandler implements EventHandler {

private final NotificationStorageHelper storageHelper;

public StoreNotificationsHandler(NotificationStorageHelper storageHelper) {
this.storageHelper = storageHelper;
}

@After
public void storeNotifications(CdsCreateEventContext context, List<Notifications> results) {
if (results == null || results.isEmpty()) {
return;
}

Notifications result = results.get(0);
Notifications requestEntry = Notifications.of(context.getCqn().entries().get(0));
storageHelper.store(result.getId(), requestEntry, Instant.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors.
*/
package com.sap.cds.notifications.handlers;

import cds.gen.notificationproviderservice.Notifications;
import com.sap.cds.notifications.helpers.NotificationStorageHelper;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.cds.ApplicationService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.After;
import com.sap.cds.services.handler.annotations.ServiceName;
import java.time.Instant;
import java.util.List;

@ServiceName(value = "*", type = ApplicationService.class)
public class StoreNotificationsLocalHandler implements EventHandler {

private final NotificationStorageHelper storageService;

public StoreNotificationsLocalHandler(NotificationStorageHelper storageService) {
this.storageService = storageService;
}

@After(event = "*")
Comment thread
lisajulia marked this conversation as resolved.
public void storeNotifications(EventContext context) {
@SuppressWarnings("unchecked")
List<Notifications> sentNotifications =
(List<Notifications>) context.get(LocalHandler.SENT_NOTIFICATIONS_KEY);

if (sentNotifications == null || sentNotifications.isEmpty()) {
return;
}

Instant sentAt = Instant.now();

for (Notifications notification : sentNotifications) {
storageService.store(notification.getId(), notification, sentAt);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors.
*/
package com.sap.cds.notifications.helpers;

import cds.gen.notificationproviderservice.Notifications;
import cds.gen.notificationproviderservice.Recipients;
import cds.gen.sap.cds.notifications.NotificationProperties;
import cds.gen.sap.cds.notifications.NotificationTargetParameters;
import com.sap.cds.ql.Insert;
import com.sap.cds.services.persistence.PersistenceService;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Shared service for persisting notifications to the database. */
public class NotificationStorageHelper {

private static final Logger logger = LoggerFactory.getLogger(NotificationStorageHelper.class);

private final PersistenceService persistenceService;

public NotificationStorageHelper(PersistenceService persistenceService) {
this.persistenceService = persistenceService;
}

public void store(String notificationId, Notifications request, Instant sentAt) {
if (notificationId == null) {
logger.warn("Skipping notification with null ID, cannot store to DB");
return;
}

logger.info(
"Storing notification '{}' for {} recipient(s) to DB",
notificationId,
request.getRecipients().size());

for (Recipients recipient : request.getRecipients()) {
Comment thread
Schmarvinius marked this conversation as resolved.
String recipientId = resolveRecipientId(recipient);
cds.gen.sap.cds.notifications.Notifications stored =
buildStoredNotification(notificationId, request, recipientId, sentAt);
persistenceService.run(
Insert.into(cds.gen.sap.cds.notifications.Notifications_.class).entry(stored));
logger.debug("Stored notification '{}' for recipient '{}'", notificationId, recipientId);
}
}

private cds.gen.sap.cds.notifications.Notifications buildStoredNotification(
String notificationId, Notifications request, String recipientId, Instant sentAt) {
cds.gen.sap.cds.notifications.Notifications stored =
cds.gen.sap.cds.notifications.Notifications.create();
stored.setId(notificationId);
stored.setRecipient(recipientId);
stored.setNotificationTypeKey(request.getNotificationTypeKey());
stored.setNotificationTemplateKey(request.getNotificationTemplateKey());
stored.setPriority(request.getPriority());
stored.setNavigationTargetObject(request.getNavigationTargetObject());
stored.setNavigationTargetAction(request.getNavigationTargetAction());
stored.setSentAt(sentAt);

if (request.getProperties() != null) {
stored.setProperties(buildProperties(request));
}

if (request.getTargetParameters() != null) {
stored.setTargetParameters(buildTargetParameters(request));
}

return stored;
}

private List<NotificationProperties> buildProperties(Notifications notification) {
List<NotificationProperties> properties = new ArrayList<>();
notification
.getProperties()
.forEach(
prop -> {
NotificationProperties p = NotificationProperties.create();
p.setPropertyKey(prop.getKey());
p.setPropertyValue(prop.getValue());
properties.add(p);
});
return properties;
}

private List<NotificationTargetParameters> buildTargetParameters(Notifications notification) {
List<NotificationTargetParameters> params = new ArrayList<>();
notification
.getTargetParameters()
.forEach(
param -> {
NotificationTargetParameters p = NotificationTargetParameters.create();
p.setParamKey(param.getKey());
p.setParamValue(param.getValue());
params.add(p);
});
return params;
}

private String resolveRecipientId(Recipients recipient) {
if (recipient.getGlobalUserId() != null && !recipient.getGlobalUserId().isBlank()) {
return recipient.getGlobalUserId();
}
return recipient.getRecipientId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace sap.cds.notifications;

@PersonalData.EntitySemantics : 'Other'
entity Notifications {
key ID : UUID not null;
@PersonalData.FieldSemantics : 'DataSubjectID'
key recipient : String(254) not null;
notificationTypeKey : String(128);
notificationTemplateKey : String;
priority : String(20);
navigationTargetObject : String(500);
navigationTargetAction : String(500);
sentAt : Timestamp;
Comment thread
lisajulia marked this conversation as resolved.
properties : Composition of many NotificationProperties
on properties.notification = $self;
targetParameters : Composition of many NotificationTargetParameters
on targetParameters.notification = $self;
}

entity NotificationProperties {
key notification : Association to Notifications not null;
key propertyKey : String(128) not null;
@PersonalData.FieldSemantics : 'IsPotentiallyPersonal'
propertyValue : String;
}

entity NotificationTargetParameters {
key notification : Association to Notifications not null;
key paramKey : String(250) not null;
@PersonalData.FieldSemantics : 'IsPotentiallyPersonal'
paramValue : String;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using from './NotificationProviderService.cds';
using from './NotificationTypeProviderService.cds';
using from './NotificationTemplateProviderService.cds';
using from './NotificationTemplateProviderService.cds';
using from './NotificationStorage';
4 changes: 4 additions & 0 deletions integration-tests/srv/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ cds:
environment:
production:
enabled: false
notifications:
storeNotifications: true
---
spring:
config.activate.on-profile: test
Expand All @@ -53,4 +55,6 @@ cds:
environment:
production:
enabled: true
notifications:
storeNotifications: true

Loading
Loading