diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1feda22..ca44463 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 4fb1028..a301604 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -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:
@@ -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:
diff --git a/cds-feature-notifications/pom.xml b/cds-feature-notifications/pom.xml
index 38a94bd..60fd027 100644
--- a/cds-feature-notifications/pom.xml
+++ b/cds-feature-notifications/pom.xml
@@ -172,7 +172,7 @@
generate-sources
- compile ${project.basedir}/srv/external/*.cds --to csn --dest ${project.build.directory}/cds-output/all.csn
+ 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
diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java
index da75db9..5940c0a 100644
--- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java
+++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java
@@ -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;
@@ -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
diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java
index 6e42d54..8f2ff03 100644
--- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java
+++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java
@@ -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;
@@ -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;
@@ -118,6 +121,16 @@ public void postNotifications(EventContext context) {
logger.info("└──────────────────────────────────────────────────────────────┘");
}
+ List 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();
}
diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/StoreNotificationsHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/StoreNotificationsHandler.java
new file mode 100644
index 0000000..7b1f7fe
--- /dev/null
+++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/StoreNotificationsHandler.java
@@ -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 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());
+ }
+}
diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/StoreNotificationsLocalHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/StoreNotificationsLocalHandler.java
new file mode 100644
index 0000000..433f876
--- /dev/null
+++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/StoreNotificationsLocalHandler.java
@@ -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 = "*")
+ public void storeNotifications(EventContext context) {
+ @SuppressWarnings("unchecked")
+ List sentNotifications =
+ (List) 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);
+ }
+ }
+}
diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/NotificationStorageHelper.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/NotificationStorageHelper.java
new file mode 100644
index 0000000..5b586d7
--- /dev/null
+++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/NotificationStorageHelper.java
@@ -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()) {
+ 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 buildProperties(Notifications notification) {
+ List 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 buildTargetParameters(Notifications notification) {
+ List 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();
+ }
+}
diff --git a/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/NotificationStorage.cds b/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/NotificationStorage.cds
new file mode 100644
index 0000000..5491390
--- /dev/null
+++ b/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/NotificationStorage.cds
@@ -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;
+ 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;
+}
diff --git a/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/index.cds b/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/index.cds
index 9c3a8cf..8f9c346 100644
--- a/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/index.cds
+++ b/cds-feature-notifications/src/main/resources/cds/com.sap.cds/cds-feature-notifications/index.cds
@@ -1,3 +1,4 @@
using from './NotificationProviderService.cds';
using from './NotificationTypeProviderService.cds';
-using from './NotificationTemplateProviderService.cds';
\ No newline at end of file
+using from './NotificationTemplateProviderService.cds';
+using from './NotificationStorage';
\ No newline at end of file
diff --git a/integration-tests/srv/src/main/resources/application.yaml b/integration-tests/srv/src/main/resources/application.yaml
index 12a5f79..f161ab6 100644
--- a/integration-tests/srv/src/main/resources/application.yaml
+++ b/integration-tests/srv/src/main/resources/application.yaml
@@ -37,6 +37,8 @@ cds:
environment:
production:
enabled: false
+ notifications:
+ storeNotifications: true
---
spring:
config.activate.on-profile: test
@@ -53,4 +55,6 @@ cds:
environment:
production:
enabled: true
+ notifications:
+ storeNotifications: true
diff --git a/integration-tests/srv/src/test/java/customer/sample_app/integration/StoreNotificationsIntegrationTest.java b/integration-tests/srv/src/test/java/customer/sample_app/integration/StoreNotificationsIntegrationTest.java
new file mode 100644
index 0000000..caa7c9b
--- /dev/null
+++ b/integration-tests/srv/src/test/java/customer/sample_app/integration/StoreNotificationsIntegrationTest.java
@@ -0,0 +1,225 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors.
+ */
+package customer.sample_app.integration;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.*;
+
+import cds.gen.my.notifications.notificationservice.CertificateExpiration;
+import cds.gen.my.notifications.notificationservice.CertificateExpirationContext;
+import cds.gen.my.notifications.notificationservice.NotificationService;
+import cds.gen.my.notifications.notificationservice.SystemMaintenance;
+import cds.gen.my.notifications.notificationservice.SystemMaintenanceContext;
+import cds.gen.sap.cds.notifications.NotificationProperties_;
+import cds.gen.sap.cds.notifications.NotificationTargetParameters_;
+import cds.gen.sap.cds.notifications.Notifications_;
+import com.sap.cds.CdsData;
+import com.sap.cds.ql.Select;
+import com.sap.cds.services.persistence.PersistenceService;
+import customer.sample_app.handlers.mock.NotificationProviderServiceMockHandler;
+import customer.sample_app.testdata.CertificateExpirationTestData;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+/**
+ * Integration tests verifying that notifications are stored to DB when storeNotifications is
+ * enabled.
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+public class StoreNotificationsIntegrationTest {
+
+ @Autowired private NotificationService.Application notificationService;
+ @Autowired private PersistenceService persistenceService;
+
+ @BeforeEach
+ void setup() {
+ NotificationProviderServiceMockHandler.clearAllNotifications();
+ }
+
+ @Test
+ void testNotificationIsStoredToDb() {
+ // Given — unique recipient to isolate from other tests
+ CertificateExpiration data =
+ CertificateExpirationTestData.builder()
+ .recipients("store-test-1@example.com")
+ .certId("cert-store-1")
+ .build();
+ CertificateExpirationContext ctx = CertificateExpirationContext.create();
+ ctx.setData(data);
+
+ // When
+ notificationService.emit(ctx);
+
+ await()
+ .atMost(5, SECONDS)
+ .until(
+ () ->
+ !persistenceService
+ .run(
+ Select.from(Notifications_.CDS_NAME)
+ .where(n -> n.get("recipient").eq("store-test-1@example.com")))
+ .listOf(CdsData.class)
+ .isEmpty());
+
+ // Then: one row in DB for this recipient
+ List rows =
+ persistenceService
+ .run(
+ Select.from(Notifications_.CDS_NAME)
+ .where(n -> n.get("recipient").eq("store-test-1@example.com")))
+ .listOf(CdsData.class);
+ assertEquals(1, rows.size(), "Should have 1 stored notification");
+
+ CdsData row = rows.get(0);
+ assertNotNull(row.get("ID"), "ID should not be null");
+ assertEquals("store-test-1@example.com", row.get("recipient"));
+ assertEquals("CertificateExpiration", row.get("notificationTypeKey"));
+ assertNotNull(row.get("sentAt"), "sentAt should not be null");
+ }
+
+ @Test
+ void testPropertiesAreStoredToDb() {
+ // Given
+ CertificateExpiration data =
+ CertificateExpirationTestData.builder()
+ .recipients("store-test-2@example.com")
+ .certId("cert-store-2")
+ .build();
+ CertificateExpirationContext ctx = CertificateExpirationContext.create();
+ ctx.setData(data);
+
+ // When
+ notificationService.emit(ctx);
+
+ await()
+ .atMost(5, SECONDS)
+ .until(
+ () ->
+ !persistenceService
+ .run(
+ Select.from(NotificationProperties_.CDS_NAME)
+ .where(
+ p ->
+ p.get("notification_recipient").eq("store-test-2@example.com")))
+ .listOf(CdsData.class)
+ .isEmpty());
+
+ // Then: non-key fields stored as properties
+ List props =
+ persistenceService
+ .run(
+ Select.from(NotificationProperties_.CDS_NAME)
+ .where(p -> p.get("notification_recipient").eq("store-test-2@example.com")))
+ .listOf(CdsData.class);
+ assertFalse(props.isEmpty(), "Should have stored notification properties");
+
+ // certId is a key field — should NOT be in properties
+ boolean certIdInProperties =
+ props.stream().anyMatch(p -> "certId".equals(p.get("propertyKey")));
+ assertFalse(certIdInProperties, "certId (key field) should not appear in properties");
+ }
+
+ @Test
+ void testTargetParametersAreStoredToDb() {
+ // Given: CertificateExpiration has key certId
+ CertificateExpiration data =
+ CertificateExpirationTestData.builder()
+ .recipients("store-test-3@example.com")
+ .certId("cert-store-3")
+ .build();
+ CertificateExpirationContext ctx = CertificateExpirationContext.create();
+ ctx.setData(data);
+
+ // When
+ notificationService.emit(ctx);
+
+ await()
+ .atMost(5, SECONDS)
+ .until(
+ () ->
+ !persistenceService
+ .run(
+ Select.from(NotificationTargetParameters_.CDS_NAME)
+ .where(
+ p ->
+ p.get("notification_recipient").eq("store-test-3@example.com")))
+ .listOf(CdsData.class)
+ .isEmpty());
+
+ // Then: certId stored as target parameter
+ List params =
+ persistenceService
+ .run(
+ Select.from(NotificationTargetParameters_.CDS_NAME)
+ .where(p -> p.get("notification_recipient").eq("store-test-3@example.com")))
+ .listOf(CdsData.class);
+ assertEquals(1, params.size(), "Should have 1 target parameter (certId)");
+ assertEquals("certId", params.get(0).get("paramKey"));
+ assertEquals("cert-store-3", params.get(0).get("paramValue"));
+ }
+
+ @Test
+ void testMultipleRecipientsCreateMultipleDbRows() {
+ // Given: SystemMaintenance has array of String recipients
+ SystemMaintenance data = SystemMaintenance.create();
+ data.setRecipients(
+ List.of(
+ "store-multi-1@example.com", "store-multi-2@example.com", "store-multi-3@example.com"));
+ data.setSystemName("TestSystem");
+ data.setMaintenanceWindow("2026-08-01 02:00-04:00");
+ data.setImpact("low");
+
+ SystemMaintenanceContext ctx = SystemMaintenanceContext.create();
+ ctx.setData(data);
+
+ // When
+ notificationService.emit(ctx);
+
+ await()
+ .atMost(5, SECONDS)
+ .until(
+ () -> {
+ List stored =
+ persistenceService
+ .run(
+ Select.from(Notifications_.CDS_NAME)
+ .where(
+ n ->
+ n.get("recipient")
+ .eq("store-multi-1@example.com")
+ .or(n.get("recipient").eq("store-multi-2@example.com"))
+ .or(n.get("recipient").eq("store-multi-3@example.com"))))
+ .listOf(CdsData.class);
+ return stored.size() == 3;
+ });
+
+ // Then: 3 rows in DB — one per recipient
+ List rows =
+ persistenceService
+ .run(
+ Select.from(Notifications_.CDS_NAME)
+ .where(
+ n ->
+ n.get("recipient")
+ .eq("store-multi-1@example.com")
+ .or(n.get("recipient").eq("store-multi-2@example.com"))
+ .or(n.get("recipient").eq("store-multi-3@example.com"))))
+ .listOf(CdsData.class);
+ assertEquals(3, rows.size(), "Should have 3 stored notifications — one per recipient");
+
+ // All should have the same notification ID
+ long distinctIds = rows.stream().map(r -> r.get("ID")).distinct().count();
+ assertEquals(1, distinctIds, "All rows should share the same notification ID");
+
+ // Each should have a different recipient
+ long distinctRecipients = rows.stream().map(r -> r.get("recipient")).distinct().count();
+ assertEquals(3, distinctRecipients, "Each row should have a distinct recipient");
+ }
+}
diff --git a/integration-tests/srv/src/test/java/customer/sample_app/integration/StoreNotificationsLocalModeIntegrationTest.java b/integration-tests/srv/src/test/java/customer/sample_app/integration/StoreNotificationsLocalModeIntegrationTest.java
new file mode 100644
index 0000000..8cf2872
--- /dev/null
+++ b/integration-tests/srv/src/test/java/customer/sample_app/integration/StoreNotificationsLocalModeIntegrationTest.java
@@ -0,0 +1,75 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors.
+ */
+package customer.sample_app.integration;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.*;
+
+import cds.gen.my.notifications.notificationservice.CertificateExpiration;
+import cds.gen.my.notifications.notificationservice.CertificateExpirationContext;
+import cds.gen.my.notifications.notificationservice.NotificationService;
+import cds.gen.sap.cds.notifications.Notifications_;
+import com.sap.cds.CdsData;
+import com.sap.cds.ql.Select;
+import com.sap.cds.services.persistence.PersistenceService;
+import customer.sample_app.testdata.CertificateExpirationTestData;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+/**
+ * Integration tests verifying that notifications are stored to DB in local mode when
+ * storeNotifications is enabled.
+ */
+@SpringBootTest
+@ActiveProfiles("default")
+public class StoreNotificationsLocalModeIntegrationTest {
+
+ @Autowired private NotificationService.Application notificationService;
+ @Autowired private PersistenceService persistenceService;
+
+ @Test
+ void testNotificationIsStoredToDbInLocalMode() {
+ // Given — unique recipient to isolate from other tests
+ CertificateExpiration data =
+ CertificateExpirationTestData.builder()
+ .recipients("local-store-test@example.com")
+ .certId("cert-local-1")
+ .build();
+ CertificateExpirationContext ctx = CertificateExpirationContext.create();
+ ctx.setData(data);
+
+ // When
+ notificationService.emit(ctx);
+
+ await()
+ .atMost(5, SECONDS)
+ .until(
+ () ->
+ !persistenceService
+ .run(
+ Select.from(Notifications_.CDS_NAME)
+ .where(n -> n.get("recipient").eq("local-store-test@example.com")))
+ .listOf(CdsData.class)
+ .isEmpty());
+
+ // Then: one row in DB for this recipient
+ List rows =
+ persistenceService
+ .run(
+ Select.from(Notifications_.CDS_NAME)
+ .where(n -> n.get("recipient").eq("local-store-test@example.com")))
+ .listOf(CdsData.class);
+ assertEquals(1, rows.size(), "Should have 1 stored notification in local mode");
+
+ CdsData row = rows.get(0);
+ assertNotNull(row.get("ID"), "ID should be generated locally");
+ assertEquals("local-store-test@example.com", row.get("recipient"));
+ assertEquals("CertificateExpiration", row.get("notificationTypeKey"));
+ assertNotNull(row.get("sentAt"), "sentAt should not be null");
+ }
+}
diff --git a/sample-app/README.md b/sample-app/README.md
index 1d06481..889a1aa 100644
--- a/sample-app/README.md
+++ b/sample-app/README.md
@@ -12,6 +12,7 @@ This sample demonstrates how to use the `cds-feature-notifications` plugin in a
- HTML email templates
- Delivery channel control (Mail only, Web only, Mail + Web)
- Semantic object navigation (deep link from notification to app)
+- Navigation target parameters via `key`-annotated event fields
- Customizable notification templates
- `where` conditions to filter when notifications are sent
@@ -62,6 +63,7 @@ When a user submits an order via the `submitOrder` action, the Java handler manu
- HTML email template (`email-templates/book-ordered.html`)
- Mail + Web delivery channels
- `@Common.SemanticObject` for deep link navigation
+- Navigation target parameters: `key bookId` field is sent as `TargetParameters` to ANS
- `customizable: true` to allow end-user configuration
- Static priority `#HIGH`
- Single string recipient (`recipients: String`)
@@ -89,6 +91,7 @@ When a user submits an order via the `submitOrder` action, the Java handler manu
}
event BookOrdered {
recipients : String; // single email or UUID
+ key bookId : UUID; // sent as TargetParameters — navigates to specific book
bookTitle : String;
quantity : Integer;
buyer : String;
@@ -98,6 +101,7 @@ event BookOrdered {
```java
BookOrdered notification = BookOrdered.create();
notification.setRecipients(context.getUserInfo().getName());
+notification.setBookId(book.getId());
notification.setBookTitle(book.getTitle());
notification.setQuantity(context.getQuantity());
notification.setBuyer(context.getUserInfo().getName());
diff --git a/sample-app/srv/notifications.cds b/sample-app/srv/notifications.cds
index f7881ef..47f09e4 100644
--- a/sample-app/srv/notifications.cds
+++ b/sample-app/srv/notifications.cds
@@ -1,9 +1,13 @@
using from 'com.sap.cds/cds-feature-notifications';
+using {sap.cds.notifications as my} from 'com.sap.cds/cds-feature-notifications';
namespace sap.capire.bookshop.notifications;
+@odata
service NotificationService {
+ entity StoredNotifications as projection on my.Notifications;
+
/**
* Example 1: Manual notification with i18n, HTML email, delivery channels,
* semantic object navigation, and customizable template.
@@ -39,6 +43,7 @@ service NotificationService {
@Common.SemanticObjectAction: 'display'
event BookOrdered {
recipients : String;
+ key bookId : UUID;
bookTitle : String;
quantity : Integer;
buyer : String;
diff --git a/sample-app/srv/pom.xml b/sample-app/srv/pom.xml
index 99d3192..231e6e4 100644
--- a/sample-app/srv/pom.xml
+++ b/sample-app/srv/pom.xml
@@ -53,7 +53,7 @@
com.sap.cds
cds-feature-notifications
- 1.0.0-SNAPSHOT
+ 0.0.2-alpha
diff --git a/sample-app/srv/src/main/java/customer/sample_app/handlers/CatalogServiceHandler.java b/sample-app/srv/src/main/java/customer/sample_app/handlers/CatalogServiceHandler.java
index c2fbcda..416bebd 100644
--- a/sample-app/srv/src/main/java/customer/sample_app/handlers/CatalogServiceHandler.java
+++ b/sample-app/srv/src/main/java/customer/sample_app/handlers/CatalogServiceHandler.java
@@ -61,6 +61,7 @@ public Books submitOrder(BooksSubmitOrderContext context) {
// Example 1: send manual notification via NotificationService
BookOrdered notification = BookOrdered.create();
notification.setRecipients(context.getUserInfo().getName());
+ notification.setBookId(bookId);
notification.setBookTitle(book.getTitle());
notification.setQuantity(context.getQuantity());
notification.setBuyer(context.getUserInfo().getName());
diff --git a/sample-app/srv/src/main/resources/application.yaml b/sample-app/srv/src/main/resources/application.yaml
index 284749d..66d904c 100644
--- a/sample-app/srv/src/main/resources/application.yaml
+++ b/sample-app/srv/src/main/resources/application.yaml
@@ -41,6 +41,8 @@ cds:
environment:
production:
enabled: false
+ notifications:
+ storeNotifications: false
---
spring:
config.activate.on-profile: test