diff --git a/CHANGELOG.md b/CHANGELOG.md index ca44463..b45c3da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,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. +- Optional cooldown mechanism: add `cooldown: ` to the `@notification` annotation to prevent the same notification from being sent to the same recipient with the same target parameters within the specified number of days. Requires `cds.notifications.storeNotifications: true`. ### 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 a301604..3b1e7e2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ In **local mode**, notifications are logged to the console with no ANS binding r - [Dynamic Priority](#dynamic-priority) - [Navigation Target Parameters](#navigation-target-parameters) - [Storing Notifications to DB](#storing-notifications-to-db) + - [Cooldown Mechanism](#cooldown-mechanism) - [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) @@ -152,6 +153,7 @@ The `template` section defines the visible content of the notification: titles, | `@notification.template.email.html` | No | Inline HTML or classpath path to HTML template file. See [Step 3](#step-3-add-email-html-template-optional) for details. | | `@notification.deliveryChannels` | No | How the notification is delivered: Web and/or Email. If omitted, notifications are delivered via Web only (no email). Each entry has: `channel` (`#Mail` or `#Web`), `enabled` (Boolean), and `defaultPreference` (Boolean, whether the channel is enabled by default for users). Note: setting `#Mail` here is not enough on its own. The ANS instance must be configured with an email infrastructure (see [ANS Service Binding](#option-1-ans-service-binding)). | | `@notification.priority` | No | You can set a static notification priority: Priority enum (`#LOW`, `#NEUTRAL`, `#MEDIUM`, `#HIGH`). Can also be a CDS expression (see [Dynamic Priority](#dynamic-priority)). | +| `@notification.cooldown` | No | Minimum number of days that must pass before sending a notification of the same type again to the same recipient with the same target parameters. Requires `cds.notifications.storeNotifications: true`. See [Cooldown Mechanism](#cooldown-mechanism). | | `@Common.SemanticObject` | No | Maps to `NavigationTargetObject` in ANS. Used for SAP Fiori launchpad navigation. Allows users to navigate directly to the relevant Fiori application when clicking the notification in SAP Build Work Zone. | | `@Common.SemanticObjectAction` | No | Maps to `NavigationTargetAction` in ANS. Specifies which action to trigger on the semantic object (e.g. `'display'`). | | `recipients` | **Yes** | Who receives the notification. Supports 4 formats (see [Recipient Formats](#recipient-formats)). | @@ -690,7 +692,7 @@ Clicking the notification in SAP Build Work Zone then opens the specific `Books( ### 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: +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](#cooldown-mechanism) or keep a history of sent notifications, you can enable DB storage: ```yaml cds: @@ -710,6 +712,30 @@ In production mode, the `ID` stored is the one returned by ANS. In local mode, a > **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. +### Cooldown Mechanism + +To prevent sending the same notification repeatedly, you can define a minimum number of days between notifications of the same type for the same recipient and target parameters: + +```cds +@notification: { + cooldown: 20, // Minimum 20 days between notifications of the same type for the same recipient and target parameters + template: { ... } +} +event ApproveCollaboration { + recipients : String; + key collaborationId : UUID; + collaborationName : String; +} +``` + +When `cooldown` is set and `storeNotifications: true` is enabled, the plugin checks the notification history before sending. If a notification of the same type was already sent to the same recipient with the same target parameters within the cooldown window, the notification is skipped. + +For example, with the `ApproveCollaboration` event above: `collaborationId` is marked with the `key` keyword, so it is used as a navigation target parameter. Before sending a new `ApproveCollaboration` notification, the plugin checks whether the same recipient was already notified about the same `collaborationId` within the last 20 days. If so, the new notification is skipped. However, if the same recipient receives a notification for a **different** `collaborationId`, it goes through because the target parameters differ. + +> **Note:** Cooldown requires `cds.notifications.storeNotifications: true`. Without DB storage, the plugin has no history to check and the `cooldown` annotation is ignored. + +> **Note:** If the event has no `key` fields, there are no target parameters. In this case, the cooldown check is based solely on the notification type and the recipient: if the same recipient already received any notification of this type within the cooldown window, the new notification is skipped. + ### 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/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java index 5940c0a..e2dae3c 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 @@ -18,6 +18,7 @@ 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.CooldownChecker; import com.sap.cds.notifications.helpers.NotificationStorageHelper; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; @@ -112,13 +113,37 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { environment.getProduction() != null && Boolean.TRUE.equals(environment.getProduction().isEnabled()); + boolean storeNotifications = + configurer + .getCdsRuntime() + .getEnvironment() + .getProperty("cds.notifications.storeNotifications", Boolean.class, false); + + PersistenceService db = null; + if (storeNotifications) { + 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"); + } + CooldownChecker cooldownChecker = new CooldownChecker(db); + if (productionEnabled || ansBindingPresent) { if (ansBindingPresent && !productionEnabled) { logger.info("alert-notification binding detected - using ProductionHandler"); } else { logger.info("Production mode enabled - using ProductionHandler"); } - configurer.eventHandler(new ProductionHandler(outboxedSvc, configurer.getCdsRuntime())); + configurer.eventHandler( + new ProductionHandler(outboxedSvc, configurer.getCdsRuntime(), cooldownChecker)); // Register handler for auto-provisioning standalone templates on application prepared event configurer.eventHandler( new NotificationTemplateAutoProvisionerHandler( @@ -128,7 +153,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new NotificationTypeAutoProvisionerHandler(configurer.getCdsRuntime(), typeProviderSvc)); } else { logger.info("Local mode enabled - using LocalHandler (notifications will be logged only)"); - configurer.eventHandler(new LocalHandler(configurer.getCdsRuntime())); + configurer.eventHandler(new LocalHandler(configurer.getCdsRuntime(), cooldownChecker)); // Register local handler for auto-provisioning standalone templates (logging only) configurer.eventHandler( new LocalNotificationTemplateAutoProvisionerHandler(configurer.getCdsRuntime())); @@ -140,27 +165,6 @@ 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 8f2ff03..7842d77 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 @@ -6,6 +6,7 @@ import cds.gen.notificationproviderservice.NotificationProperties; import cds.gen.notificationproviderservice.Notifications; import com.sap.cds.notifications.assemblers.NotificationAssembler; +import com.sap.cds.notifications.helpers.CooldownChecker; import com.sap.cds.notifications.helpers.I18nHelper; import com.sap.cds.reflect.CdsEvent; import com.sap.cds.services.EventContext; @@ -30,10 +31,12 @@ public class LocalHandler implements EventHandler { static final String SENT_NOTIFICATIONS_KEY = "com.sap.cds.notifications.stored"; private final NotificationAssembler notificationBuilder; private final I18nHelper i18nHelper; + private final CooldownChecker cooldownChecker; - public LocalHandler(CdsRuntime runtime) { + public LocalHandler(CdsRuntime runtime, CooldownChecker cooldownChecker) { this.notificationBuilder = new NotificationAssembler(runtime); this.i18nHelper = new I18nHelper(runtime); + this.cooldownChecker = cooldownChecker; } @On(event = "*") @@ -44,11 +47,28 @@ public void postNotifications(EventContext context) { return; } + List sentNotifications = new ArrayList<>(); + for (int i = 0; i < results.size(); i++) { NotificationAssembler.NotificationBuildResult result = results.get(i); Notifications notification = result.notification(); CdsEvent event = result.event(); + notification = cooldownChecker.filterCooldownRecipients(event, notification); + if (notification == null) { + logger.debug( + "Skipping notification {}/{} for event '{}' - all recipients in cooldown", + i + 1, + results.size(), + result.eventName()); + continue; + } + + if (notification.getId() == null) { + notification.setId(UUID.randomUUID().toString()); + } + sentNotifications.add(notification); + Map props = notification.getProperties().stream() .collect( @@ -120,15 +140,6 @@ 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/ProductionHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/ProductionHandler.java index 97c638c..f32e432 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/ProductionHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/ProductionHandler.java @@ -7,7 +7,9 @@ import cds.gen.notificationproviderservice.Notifications; import cds.gen.notificationproviderservice.Notifications_; import com.sap.cds.notifications.assemblers.NotificationAssembler; +import com.sap.cds.notifications.helpers.CooldownChecker; import com.sap.cds.ql.Insert; +import com.sap.cds.reflect.CdsEvent; import com.sap.cds.services.EventContext; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.handler.EventHandler; @@ -24,11 +26,15 @@ public class ProductionHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(ProductionHandler.class); private final NotificationProviderService notificationProviderService; private final NotificationAssembler notificationBuilder; + private final CooldownChecker cooldownChecker; public ProductionHandler( - NotificationProviderService notificationProviderService, CdsRuntime runtime) { + NotificationProviderService notificationProviderService, + CdsRuntime runtime, + CooldownChecker cooldownChecker) { this.notificationProviderService = notificationProviderService; this.notificationBuilder = new NotificationAssembler(runtime); + this.cooldownChecker = cooldownChecker; } @On(event = "*") @@ -40,6 +46,7 @@ public void postNotifications(EventContext context) { } String eventName = results.get(0).eventName(); + CdsEvent event = results.get(0).event(); logger.debug("=== Processing {} notification(s) for event: {} ===", results.size(), eventName); int successCount = 0; @@ -47,6 +54,18 @@ public void postNotifications(EventContext context) { for (int i = 0; i < results.size(); i++) { Notifications notification = results.get(i).notification(); + + notification = cooldownChecker.filterCooldownRecipients(event, notification); + if (notification == null) { + logger.debug( + "Skipping notification {}/{} for event '{}' - all recipients in cooldown", + i + 1, + results.size(), + eventName); + successCount++; + continue; + } + try { notificationProviderService.run(Insert.into(Notifications_.CDS_NAME).entry(notification)); successCount++; diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/CooldownChecker.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/CooldownChecker.java new file mode 100644 index 0000000..7606b60 --- /dev/null +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/CooldownChecker.java @@ -0,0 +1,144 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. + */ +package com.sap.cds.notifications.helpers; + +import cds.gen.notificationproviderservice.NavigationTargetParams; +import cds.gen.notificationproviderservice.Notifications; +import cds.gen.notificationproviderservice.Recipients; +import cds.gen.sap.cds.notifications.NotificationTargetParameters; +import cds.gen.sap.cds.notifications.Notifications_; +import com.sap.cds.Result; +import com.sap.cds.ql.Select; +import com.sap.cds.reflect.CdsEvent; +import com.sap.cds.services.persistence.PersistenceService; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CooldownChecker { + + private static final Logger logger = LoggerFactory.getLogger(CooldownChecker.class); + + private final PersistenceService persistenceService; + + public CooldownChecker(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + /** + * Filters out recipients that are within the cooldown window from the given notification. Returns + * the notification with only the recipients that should receive it, or null if all recipients are + * in cooldown and the notification should be skipped entirely. + */ + public Notifications filterCooldownRecipients(CdsEvent event, Notifications notification) { + if (persistenceService == null) { + return notification; + } + + var cooldownAnnotation = event.findAnnotation("notification.cooldown"); + if (cooldownAnnotation.isEmpty()) { + return notification; + } + + int cooldownDays; + try { + cooldownDays = ((Number) cooldownAnnotation.get().getValue()).intValue(); + } catch (Exception e) { + logger.warn( + "Invalid cooldown value for event '{}', skipping cooldown check", event.getName()); + return notification; + } + + if (cooldownDays <= 0) { + return notification; + } + + Instant cutoff = Instant.now().minus(cooldownDays, ChronoUnit.DAYS); + String typeKey = notification.getNotificationTypeKey(); + Map currentTargetParams = + buildTargetParamsMap(notification.getTargetParameters()); + + List filtered = + notification.getRecipients().stream() + .filter( + r -> { + String recipientId = NotificationStorageHelper.resolveRecipientId(r); + boolean inCooldown = + isInCooldown(typeKey, recipientId, cutoff, currentTargetParams); + if (inCooldown) { + logger.debug( + "Recipient '{}' is in cooldown for notification type '{}'", + recipientId, + typeKey); + } + return !inCooldown; + }) + .collect(Collectors.toList()); + + if (filtered.isEmpty()) { + return null; + } + + notification.setRecipients(filtered); + return notification; + } + + private boolean isInCooldown( + String typeKey, String recipientId, Instant cutoff, Map currentTargetParams) { + try { + Result result = + persistenceService.run( + Select.from(Notifications_.class) + .columns(n -> n.ID(), n -> n.sentAt(), n -> n.targetParameters().expand()) + .where( + n -> + n.notificationTypeKey() + .eq(typeKey) + .and(n.recipient().eq(recipientId)) + .and(n.sentAt().gt(cutoff)))); + + for (cds.gen.sap.cds.notifications.Notifications stored : + result.listOf(cds.gen.sap.cds.notifications.Notifications.class)) { + Map storedParams = buildStoredParamsMap(stored.getTargetParameters()); + if (storedParams.equals(currentTargetParams)) { + return true; + } + } + } catch (Exception e) { + logger.warn( + "Failed to check cooldown for recipient '{}', type '{}': {}", + recipientId, + typeKey, + e.getMessage()); + } + return false; + } + + private Map buildTargetParamsMap(List params) { + if (params == null) { + return Map.of(); + } + return params.stream() + .filter(p -> p.getKey() != null) + .collect( + Collectors.toMap( + NavigationTargetParams::getKey, p -> p.getValue() != null ? p.getValue() : "")); + } + + private Map buildStoredParamsMap(List params) { + if (params == null) { + return Map.of(); + } + return params.stream() + .filter(p -> p.getParamKey() != null) + .collect( + Collectors.toMap( + NotificationTargetParameters::getParamKey, + p -> p.getParamValue() != null ? p.getParamValue() : "")); + } +} 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 index 5b586d7..62ba1ba 100644 --- 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 @@ -99,7 +99,7 @@ private List buildTargetParameters(Notifications n return params; } - private String resolveRecipientId(Recipients recipient) { + static String resolveRecipientId(Recipients recipient) { if (recipient.getGlobalUserId() != null && !recipient.getGlobalUserId().isBlank()) { return recipient.getGlobalUserId(); } diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/helpers/CooldownCheckerTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/helpers/CooldownCheckerTest.java new file mode 100644 index 0000000..c9b45ec --- /dev/null +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/helpers/CooldownCheckerTest.java @@ -0,0 +1,222 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. + */ +package com.sap.cds.notifications.helpers; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import cds.gen.notificationproviderservice.NavigationTargetParams; +import cds.gen.notificationproviderservice.Notifications; +import cds.gen.notificationproviderservice.Recipients; +import cds.gen.sap.cds.notifications.NotificationTargetParameters; +import com.sap.cds.Result; +import com.sap.cds.Struct; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsEvent; +import com.sap.cds.services.persistence.PersistenceService; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CooldownCheckerTest { + + private PersistenceService persistenceService; + private CooldownChecker cooldownChecker; + + @BeforeEach + void setUp() { + persistenceService = mock(PersistenceService.class); + cooldownChecker = new CooldownChecker(persistenceService); + } + + @Nested + @DisplayName("filterCooldownRecipients") + class FilterCooldownRecipients { + + @Test + @DisplayName("returns notification unchanged when persistence service is not available") + void returnsNotificationUnchangedWhenPersistenceServiceIsNull() { + CooldownChecker checkerWithoutDb = new CooldownChecker(null); + CdsEvent event = mockEventWithCooldown(20); + Notifications notification = buildNotification("user@example.com", "BookOrdered", List.of()); + + Notifications result = checkerWithoutDb.filterCooldownRecipients(event, notification); + + assertSame(notification, result); + } + + @Test + @DisplayName("returns notification unchanged when persistence service throws an exception") + void returnsNotificationUnchangedWhenPersistenceServiceThrows() { + CdsEvent event = mockEventWithCooldown(20); + Notifications notification = buildNotification("user@example.com", "BookOrdered", List.of()); + + when(persistenceService.run(any(CqnSelect.class))) + .thenThrow(new RuntimeException("DB error")); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertSame(notification, result); + } + + @Test + @DisplayName("returns notification unchanged when no cooldown annotation is present") + void returnsNotificationUnchangedWhenNoCooldownAnnotation() { + CdsEvent event = mock(CdsEvent.class); + when(event.findAnnotation("notification.cooldown")).thenReturn(Optional.empty()); + + Notifications notification = buildNotification("user@example.com", "BookOrdered", List.of()); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertSame(notification, result); + } + + @Test + @DisplayName("returns notification unchanged when cooldown is zero or negative") + void returnsNotificationUnchangedWhenCooldownIsZero() { + CdsEvent event = mockEventWithCooldown(0); + Notifications notification = buildNotification("user@example.com", "BookOrdered", List.of()); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertSame(notification, result); + } + + @Test + @DisplayName("returns notification unchanged when no stored notifications exist") + void returnsNotificationUnchangedWhenNoStoredNotificationsExist() { + CdsEvent event = mockEventWithCooldown(20); + + Notifications notification = buildNotification("user@example.com", "BookOrdered", List.of()); + + Result mockResult = mock(Result.class); + when(mockResult.listOf(cds.gen.sap.cds.notifications.Notifications.class)) + .thenReturn(List.of()); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(mockResult); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertSame(notification, result); + } + + @Test + @DisplayName("returns null when all recipients are within cooldown window and no target params") + void returnsNullWhenAllRecipientsInCooldownWithNoTargetParams() { + CdsEvent event = mockEventWithCooldown(20); + + Notifications notification = buildNotification("user@example.com", "BookOrdered", List.of()); + + cds.gen.sap.cds.notifications.Notifications stored = + cds.gen.sap.cds.notifications.Notifications.create(); + stored.setId("some-id"); + stored.setSentAt(Instant.now().minus(5, ChronoUnit.DAYS)); + stored.setTargetParameters(List.of()); + + Result mockResult = mock(Result.class); + when(mockResult.listOf(cds.gen.sap.cds.notifications.Notifications.class)) + .thenReturn(List.of(stored)); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(mockResult); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertNull(result); + } + + @Test + @DisplayName("returns null when recipient has same target params within cooldown window") + void returnsNullWhenSameTargetParamsWithinCooldownWindow() { + CdsEvent event = mockEventWithCooldown(20); + + List targetParams = List.of(buildTargetParam("bookId", "123")); + Notifications notification = + buildNotification("user@example.com", "BookOrdered", targetParams); + + cds.gen.sap.cds.notifications.Notifications stored = + cds.gen.sap.cds.notifications.Notifications.create(); + stored.setId("some-id"); + stored.setSentAt(Instant.now().minus(5, ChronoUnit.DAYS)); + NotificationTargetParameters storedParam = NotificationTargetParameters.create(); + storedParam.setParamKey("bookId"); + storedParam.setParamValue("123"); + stored.setTargetParameters(List.of(storedParam)); + + Result mockResult = mock(Result.class); + when(mockResult.listOf(cds.gen.sap.cds.notifications.Notifications.class)) + .thenReturn(List.of(stored)); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(mockResult); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertNull(result); + } + + @Test + @DisplayName( + "returns notification unchanged when stored notification has different target params") + void returnsNotificationUnchangedWhenDifferentTargetParams() { + CdsEvent event = mockEventWithCooldown(20); + + List targetParams = List.of(buildTargetParam("bookId", "123")); + Notifications notification = + buildNotification("user@example.com", "BookOrdered", targetParams); + + cds.gen.sap.cds.notifications.Notifications stored = + cds.gen.sap.cds.notifications.Notifications.create(); + stored.setId("some-id"); + stored.setSentAt(Instant.now().minus(5, ChronoUnit.DAYS)); + NotificationTargetParameters storedParam = NotificationTargetParameters.create(); + storedParam.setParamKey("bookId"); + storedParam.setParamValue("999"); // different value + stored.setTargetParameters(List.of(storedParam)); + + Result mockResult = mock(Result.class); + when(mockResult.listOf(cds.gen.sap.cds.notifications.Notifications.class)) + .thenReturn(List.of(stored)); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(mockResult); + + Notifications result = cooldownChecker.filterCooldownRecipients(event, notification); + + assertSame(notification, result); + } + } + + private CdsEvent mockEventWithCooldown(int days) { + CdsEvent event = mock(CdsEvent.class); + @SuppressWarnings("unchecked") + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn(days); + when(event.findAnnotation("notification.cooldown")).thenReturn(Optional.of(annotation)); + return event; + } + + private Notifications buildNotification( + String recipientEmail, String typeKey, List targetParams) { + Notifications notification = Struct.create(Notifications.class); + notification.setNotificationTypeKey(typeKey); + notification.setTargetParameters(targetParams); + + Recipients recipient = Struct.create(Recipients.class); + recipient.setRecipientId(recipientEmail); + notification.setRecipients(List.of(recipient)); + + return notification; + } + + private NavigationTargetParams buildTargetParam(String key, String value) { + NavigationTargetParams param = Struct.create(NavigationTargetParams.class); + param.setKey(key); + param.setValue(value); + return param; + } +} diff --git a/integration-tests/srv/notificationtypes-data.cds b/integration-tests/srv/notificationtypes-data.cds index e0b8816..43357f5 100644 --- a/integration-tests/srv/notificationtypes-data.cds +++ b/integration-tests/srv/notificationtypes-data.cds @@ -258,6 +258,23 @@ service NotificationService { version: String; } + // Cooldown test event — used by CooldownIntegrationTest only + @notification : { + cooldown: 2, + template: { + title : 'Reminder: {{message}}', + publicTitle : 'Reminder', + subtitle : 'Please take action', + groupedTitle : 'Reminders', + }, + priority : 'MEDIUM', + } + event ReminderNotification { + recipients: array of String; + key reminderId: String; + message: String; + } + // Future notification events here } diff --git a/integration-tests/srv/src/test/java/customer/sample_app/integration/CooldownIntegrationTest.java b/integration-tests/srv/src/test/java/customer/sample_app/integration/CooldownIntegrationTest.java new file mode 100644 index 0000000..6500da8 --- /dev/null +++ b/integration-tests/srv/src/test/java/customer/sample_app/integration/CooldownIntegrationTest.java @@ -0,0 +1,292 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. + */ +package customer.sample_app.integration; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cds.gen.my.notifications.notificationservice.NotificationService; +import cds.gen.my.notifications.notificationservice.ReminderNotification; +import cds.gen.my.notifications.notificationservice.ReminderNotificationContext; +import cds.gen.sap.cds.notifications.NotificationTargetParameters; +import cds.gen.sap.cds.notifications.Notifications; +import cds.gen.sap.cds.notifications.Notifications_; +import com.sap.cds.CdsData; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.persistence.PersistenceService; +import customer.sample_app.handlers.mock.NotificationProviderServiceMockHandler; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +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 the cooldown mechanism: a notification is skipped if the same type + * was already sent to the same recipient with the same target parameters within the cooldown + * window. + */ +@SpringBootTest +@ActiveProfiles("test") +public class CooldownIntegrationTest { + + @Autowired private NotificationService.Application notificationService; + @Autowired private PersistenceService persistenceService; + + @BeforeEach + void setup() { + NotificationProviderServiceMockHandler.clearAllNotifications(); + } + + @Test + void testSecondNotificationSkippedWhenInCooldown() { + String recipient = "cooldown-test-1@example.com"; + String reminderId = "reminder-cd-1"; + + // Insert a recent notification record directly into DB (1 day ago, cooldown is 2 days) + Notifications recent = Notifications.create(); + recent.setId(UUID.randomUUID().toString()); + recent.setRecipient(recipient); + recent.setNotificationTypeKey("ReminderNotification"); + recent.setNotificationTemplateKey("ReminderNotification"); + recent.setSentAt(Instant.now().minus(1, ChronoUnit.DAYS)); + NotificationTargetParameters param = NotificationTargetParameters.create(); + param.setParamKey("reminderId"); + param.setParamValue(reminderId); + recent.setTargetParameters(List.of(param)); + persistenceService.run(Insert.into(Notifications_.CDS_NAME).entry(recent)); + + // Send notification — cooldown active → should be skipped + ReminderNotification data = buildReminder(recipient, reminderId); + ReminderNotificationContext ctx = ReminderNotificationContext.create(); + ctx.setData(data); + + notificationService.emit(ctx); + + await() + .during(500, MILLISECONDS) + .atMost(1, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 0); + + assertEquals( + 0, + NotificationProviderServiceMockHandler.getNotificationCount(), + "Notification should be skipped due to cooldown"); + } + + @Test + void testNotificationNotBlockedByDifferentTargetParams() { + // First send with reminderId "reminder-cd-2a" + ReminderNotification firstData = buildReminder("cooldown-test-2@example.com", "reminder-cd-2a"); + ReminderNotificationContext firstCtx = ReminderNotificationContext.create(); + firstCtx.setData(firstData); + + notificationService.emit(firstCtx); + await() + .atMost(5, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 1); + + await() + .atMost(5, SECONDS) + .until( + () -> + !persistenceService + .run( + Select.from(Notifications_.CDS_NAME) + .where(n -> n.get("recipient").eq("cooldown-test-2@example.com"))) + .listOf(CdsData.class) + .isEmpty()); + + NotificationProviderServiceMockHandler.clearAllNotifications(); + + // Second send with DIFFERENT reminderId "reminder-cd-2b" — different target params, + // so cooldown does not apply even for the same recipient + ReminderNotification secondData = + buildReminder("cooldown-test-2@example.com", "reminder-cd-2b"); + ReminderNotificationContext secondCtx = ReminderNotificationContext.create(); + secondCtx.setData(secondData); + + notificationService.emit(secondCtx); + await() + .atMost(5, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 1); + + assertEquals( + 1, + NotificationProviderServiceMockHandler.getNotificationCount(), + "Notification with different target params should go through regardless of cooldown"); + + cds.gen.notificationproviderservice.Notifications sent = + NotificationProviderServiceMockHandler.getAllNotifications().get(0); + assertEquals( + "reminder-cd-2b", + sent.getTargetParameters().get(0).getValue(), + "Sent notification should have the new reminderId as target parameter"); + } + + @Test + void testCooldownDoesNotApplyToRecipientsNotPreviouslyNotified() { + String recipientInCooldown = "cooldown-test-3a@example.com"; + String recipientFresh = "cooldown-test-3b@example.com"; + + // Pre-send for recipientInCooldown + ReminderNotification firstData = buildReminder(recipientInCooldown, "reminder-cd-3"); + ReminderNotificationContext firstCtx = ReminderNotificationContext.create(); + firstCtx.setData(firstData); + + notificationService.emit(firstCtx); + await() + .atMost(5, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 1); + + // Wait for DB storage before second emit + await() + .atMost(5, SECONDS) + .until( + () -> + !persistenceService + .run( + Select.from(Notifications_.CDS_NAME) + .where(n -> n.get("recipient").eq(recipientInCooldown))) + .listOf(CdsData.class) + .isEmpty()); + + NotificationProviderServiceMockHandler.clearAllNotifications(); + + // Emit for recipientFresh — same reminderId, different recipient → should go through + ReminderNotification secondData = buildReminder(recipientFresh, "reminder-cd-3"); + ReminderNotificationContext secondCtx = ReminderNotificationContext.create(); + secondCtx.setData(secondData); + + notificationService.emit(secondCtx); + await() + .atMost(5, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 1); + + cds.gen.notificationproviderservice.Notifications sent = + NotificationProviderServiceMockHandler.getAllNotifications().get(0); + assertEquals( + recipientFresh, + sent.getRecipients().get(0).getRecipientId(), + "Notification should be sent to the fresh recipient"); + } + + @Test + void testNotificationSentAfterCooldownExpires() { + String recipient = "cooldown-test-4@example.com"; + String reminderId = "reminder-cd-4"; + + // Insert an expired notification record directly into DB (3 days ago, cooldown is 2 days) + Notifications expired = Notifications.create(); + expired.setId(UUID.randomUUID().toString()); + expired.setRecipient(recipient); + expired.setNotificationTypeKey("ReminderNotification"); + expired.setNotificationTemplateKey("ReminderNotification"); + expired.setSentAt(Instant.now().minus(3, ChronoUnit.DAYS)); + NotificationTargetParameters param = NotificationTargetParameters.create(); + param.setParamKey("reminderId"); + param.setParamValue(reminderId); + expired.setTargetParameters(List.of(param)); + persistenceService.run(Insert.into(Notifications_.CDS_NAME).entry(expired)); + + // Send notification — cooldown has expired → should go through + ReminderNotification data = buildReminder(recipient, reminderId); + ReminderNotificationContext ctx = ReminderNotificationContext.create(); + ctx.setData(data); + + notificationService.emit(ctx); + await() + .atMost(5, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 1); + + assertEquals( + 1, + NotificationProviderServiceMockHandler.getNotificationCount(), + "Notification should be sent when cooldown has expired"); + + cds.gen.notificationproviderservice.Notifications sent = + NotificationProviderServiceMockHandler.getAllNotifications().get(0); + assertEquals( + recipient, + sent.getRecipients().get(0).getRecipientId(), + "Notification should be sent to the correct recipient"); + + // Also verify it was stored in DB (new row added) + await() + .atMost(5, SECONDS) + .until( + () -> + persistenceService + .run( + Select.from(Notifications_.CDS_NAME) + .where(n -> n.get("recipient").eq(recipient))) + .listOf(CdsData.class) + .size() + == 2); + + assertEquals( + 2, + persistenceService + .run(Select.from(Notifications_.CDS_NAME).where(n -> n.get("recipient").eq(recipient))) + .listOf(CdsData.class) + .size(), + "A new DB record should be added after cooldown expires"); + } + + @Test + void testCooldownFiltersOnlyRecipientsInCooldown() { + String reminderId = "reminder-cd-5"; + + // Insert recent records for alice and bob (in cooldown) + for (String recipient : List.of("alice@example.com", "bob@example.com")) { + Notifications recent = Notifications.create(); + recent.setId(UUID.randomUUID().toString()); + recent.setRecipient(recipient); + recent.setNotificationTypeKey("ReminderNotification"); + recent.setNotificationTemplateKey("ReminderNotification"); + recent.setSentAt(Instant.now().minus(1, ChronoUnit.DAYS)); + NotificationTargetParameters param = NotificationTargetParameters.create(); + param.setParamKey("reminderId"); + param.setParamValue(reminderId); + recent.setTargetParameters(List.of(param)); + persistenceService.run(Insert.into(Notifications_.CDS_NAME).entry(recent)); + } + + // Send to all 3 — alice and bob in cooldown, charlie is not + ReminderNotification data = ReminderNotification.create(); + data.setRecipients(List.of("alice@example.com", "bob@example.com", "charlie@example.com")); + data.setReminderId(reminderId); + data.setMessage("Please complete your pending action"); + ReminderNotificationContext ctx = ReminderNotificationContext.create(); + ctx.setData(data); + + notificationService.emit(ctx); + await() + .atMost(5, SECONDS) + .until(() -> NotificationProviderServiceMockHandler.getNotificationCount() == 1); + + cds.gen.notificationproviderservice.Notifications sent = + NotificationProviderServiceMockHandler.getAllNotifications().get(0); + assertEquals(1, sent.getRecipients().size(), "Only charlie should receive the notification"); + assertEquals( + "charlie@example.com", + sent.getRecipients().get(0).getRecipientId(), + "Charlie should be the only recipient"); + } + + private ReminderNotification buildReminder(String recipient, String reminderId) { + ReminderNotification data = ReminderNotification.create(); + data.setRecipients(List.of(recipient)); + data.setReminderId(reminderId); + data.setMessage("Please complete your pending action"); + return data; + } +} diff --git a/integration-tests/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java b/integration-tests/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java index fd352ff..c6d7817 100644 --- a/integration-tests/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java +++ b/integration-tests/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java @@ -41,7 +41,8 @@ public class NotificationTypeProvisioningTest { "ContractDeadline", "SecurityAlert", "ServerIncident", - "DeploymentNotification"); + "DeploymentNotification", + "ReminderNotification"); private NotificationTypeAutoProvisionerHandler createProvisioner() { return new NotificationTypeAutoProvisionerHandler(cdsRuntime, notificationTypeProviderService); diff --git a/sample-app/srv/notifications.cds b/sample-app/srv/notifications.cds index 47f09e4..e34d2d8 100644 --- a/sample-app/srv/notifications.cds +++ b/sample-app/srv/notifications.cds @@ -55,6 +55,7 @@ service NotificationService { * The plugin auto-detects whether each value is an email or a UUID. */ @notification: { + cooldown: 3, // Minimum 3 days between same alerts for the same recipient and book template: { title : 'Low stock alert for "{{bookTitle}}"', publicTitle : 'Low stock alert', @@ -68,6 +69,7 @@ service NotificationService { } event LowStockAlert { recipients : array of String; // Case 2: multiple recipients (emails or UUIDs) + key bookId : UUID; bookTitle : String; stock : Integer; }