Skip to content
Open
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 @@ -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.requires.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
35 changes: 35 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,29 @@ 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:
requires:
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. 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. Event fields not annotated with the `key` keyword. |
| `sap.cds.notifications.NotificationTargetParameters` | Navigation target parameters. Event fields 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.NotificationStorageService;
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.requires.notifications.storeNotifications", Boolean.class, false);

@Schmarvinius Schmarvinius Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cds.requires is the namespace for require service confiugration -> feature toggles are in cds.

Suggested change
.getProperty("cds.requires.notifications.storeNotifications", Boolean.class, false);
.getProperty("cds.notifications.storeNotifications", Boolean.class, false);


if (storeNotifications) {
PersistenceService db =
configurer
.getCdsRuntime()
.getServiceCatalog()
.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME);
NotificationStorageService storageService = new NotificationStorageService(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,46 @@
/*
* © 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 cds.gen.notificationproviderservice.Notifications_;
import com.sap.cds.notifications.helpers.NotificationStorageService;
import com.sap.cds.services.Service;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.cds.CqnService;
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;
import java.util.Map;

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

private final NotificationStorageService storageService;

public StoreNotificationsHandler(NotificationStorageService storageService) {
this.storageService = storageService;
}

@After(event = CqnService.EVENT_CREATE, entity = Notifications_.CDS_NAME)
public void storeNotifications(CdsCreateEventContext context) {
Comment on lines +29 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be simplified

Suggested change
@After(event = CqnService.EVENT_CREATE, entity = Notifications_.CDS_NAME)
public void storeNotifications(CdsCreateEventContext context) {
@After
public void storeNotifications(CdsCreateEventContext context, List<CdsData> data) {

List<Notifications> results = context.getResult().listOf(Notifications.class);
List<Map<String, Object>> entries = context.getCqn().entries();

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

Instant sentAt = Instant.now();

for (int i = 0; i < results.size(); i++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly add a check here that results and entries have the same size.

Notifications result = results.get(i);
Notifications requestEntry = Notifications.of(entries.get(i));
storageService.store(result.getId(), requestEntry, sentAt);
}
}
}
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.NotificationStorageService;
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 NotificationStorageService storageService;

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

@After(event = "*")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be after every event, right? Is it possible to narrow that down?

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 NotificationStorageService {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class isn't a service (in cap terms) it is a normal pojo


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

private final PersistenceService persistenceService;

public NotificationStorageService(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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question can getRecipients() be null? If yes thats a possible npe

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention in the readme that this is UTC time.

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';
Loading
Loading