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