Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <days>` 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
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)). |
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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()));
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "*")
Expand All @@ -44,11 +47,28 @@ public void postNotifications(EventContext context) {
return;
}

List<Notifications> 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<String, String> props =
notification.getProperties().stream()
.collect(
Expand Down Expand Up @@ -120,15 +140,6 @@ public void postNotifications(EventContext context) {
}
logger.info("└──────────────────────────────────────────────────────────────┘");
}

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

context.setCompleted();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "*")
Expand All @@ -40,13 +46,26 @@ 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;
Exception firstError = null;

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++;
Expand Down
Loading
Loading