Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
02ebbac
feat: add NotificationTemplateProviderService as remote service
busehalis-sap May 27, 2026
a9c03c9
feat: add standalone NotificationTemplate provisioning and extract I1…
busehalis-sap May 28, 2026
3039c6e
refactor: use @UI.AdaptationHidden annotation for template visibility…
busehalis-sap Jun 2, 2026
57599b2
refactor: use @notification.customizable annotation for template visi…
busehalis-sap Jun 2, 2026
24960d1
test: add integration tests for standalone template provisioning
busehalis-sap Jun 2, 2026
216d2b1
refactor: reduce log verbosity, per-item INFO to DEBUG
busehalis-sap Jun 2, 2026
6bf4dfe
Merge branch 'main' into feature/notification-template-provisioning
busehalis-sap Jun 15, 2026
dcf4ec4
Merge remote-tracking branch 'origin/main' into feature/notification-…
busehalis-sap Jun 16, 2026
cd0c284
chore: update CHANGELOG with standalone notification template provisi…
busehalis-sap Jun 16, 2026
412310e
test: add unit tests for I18nHelper
busehalis-sap Jun 16, 2026
dae8c70
test: verify re-provisioning updates existing notification templates
busehalis-sap Jun 16, 2026
7ee6a79
test: add local mode integration tests for LocalHandler and local pro…
busehalis-sap Jun 16, 2026
e9f74c4
test: add unit tests for I18nHelper getAvailableLocales and getI18nTe…
busehalis-sap Jun 16, 2026
4a57dc2
refactor: move builder classes from handlers to builders package
busehalis-sap Jun 16, 2026
14165c7
refactor: replace System.out.println with logger and use text blocks …
busehalis-sap Jun 16, 2026
651b4ed
chore: remove debug System.out.println statements from NotificationSe…
busehalis-sap Jun 16, 2026
42b83fb
refactor: use Jackson ObjectMapper instead of manual string building …
busehalis-sap Jun 16, 2026
99e8ab2
refactor: rename builder classes to assemblers and move to assemblers…
busehalis-sap Jun 16, 2026
5e90602
refactor: simplify extractVisibility using getAnnotationValue
busehalis-sap Jun 16, 2026
a874ea2
chore: remove redundant remote service definitions from application.yaml
busehalis-sap Jun 16, 2026
fc6cdf5
refactor: replace fully qualified type names with imports in Notifica…
busehalis-sap Jun 17, 2026
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
- Navigation target support via `@Common.SemanticObject` and `@Common.SemanticObjectAction` annotations for SAP Fiori Launchpad integration
- Recipient language resolution via Identity Authentication Service (IAS) destination `Identity_Authentication_Connectivity_IDS`
- Persistent outbox integration for guaranteed in-order delivery with automatic retry on failure
- Automatic registration of `NotificationProviderService` and `NotificationTypeProviderService` as OData v2 remote services
- Automatic registration of `NotificationProviderService`, `NotificationTypeProviderService`, and `NotificationTemplateProviderService` as OData v2 remote services
- Standalone notification template provisioning via `@notification.customizable` annotation for user-customizable templates
- Support for SAP ANS standalone service binding (`business-notifications` plan) and SAP Work Zone credentials
- Sample application demonstrating programmatic and declarative notification patterns with integration tests for Java 17 and 21
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ In **local mode**, notifications are logged to the console with no ANS binding r
- [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)
- [Step 2: Create the BTP Destination](#step-2-create-the-btp-destination)
- [Template Customization](#template-customization)
- [Outbox](#outbox)
- [Minimum Versions](#minimum-versions)
- [Monitoring & Logging](#monitoring--logging)
Expand Down Expand Up @@ -138,6 +139,7 @@ The `template` section defines the visible content of the notification: titles,
| Annotation | Required | Description |
|---|---|---|
| `@description` | No | Description label of a notification type. Shown to administrators and end-users in the SAP Build Work Zone notification type management UI. |
| `@notification.customizable` | No | Controls whether customer administrators can see and customize this template. `true` = PUBLIC (visible for customization), absent or `false` = PRIVATE (default). See [Template Customization](#template-customization). |
| `@notification.template.title` | **Yes** | Detailed notification title, shown to the recipient and authorized users. May contain sensitive information (e.g. "Low stock: Wuthering Heights by Emily Brontë"). Mapped to `TemplateSensitive` in ANS. |
| `@notification.template.publicTitle` | **Yes** | Short, non-sensitive title shown when the viewer is not authorized to see the full details (e.g. "Low Stock Alert"). Mapped to `TemplatePublic` in ANS. |
| `@notification.template.subtitle` | **Yes** | Subtitle for the notification. |
Expand All @@ -151,6 +153,8 @@ The `template` section defines the visible content of the notification: titles,
| `recipients` | **Yes** | Who receives the notification. Supports 4 formats (see [Recipient Formats](#recipient-formats)). |
| Event fields | No | Define `{{variableName}}` (Mustache syntax) placeholders in your templates and matching fields in the CDS event. When you emit a notification, set these fields with the actual values. The plugin passes them to ANS, which replaces the placeholders at delivery time. |

> **Important:** Event names must be **unique across all services** in your application. The event name is used as the key for both NotificationType and NotificationTemplate in ANS. If two services define an event with the same name (e.g. `LowStockAlert`), the last one provisioned will silently overwrite the other.

#### Step 2: Add i18n Translations (Optional)

Create i18n property files under `srv/_i18n/` to define the `{i18n>KEY}` placeholders used in your annotation values. Each entry follows the `KEY=value` format, where the key matches the placeholder name in the annotation value.
Expand Down Expand Up @@ -715,6 +719,27 @@ Once configured, ANS can resolve each recipient's language preference from IAS a

For the full setup guide, see [Identity Directory Connectivity](https://help.sap.com/docs/task-center/sap-task-center/identity-directory-connectivity?q=destination) in the SAP Task Center documentation.

### Template Customization

The plugin automatically provisions notification templates to ANS during application startup. These templates enable customer administrators to create customized copies of your notification content for their organization. For details on how template customization works from the admin perspective, see the [Template Customization documentation](https://github.wdf.sap.corp/pages/sl-hybrid/ans/notifications-scenario/template-customization/).

By default, templates are `PRIVATE` (not visible to customer admins). To make a template visible and customizable by admins, add `@notification.customizable: true`:

```cds
@notification : {
customizable: true,
template: {
title : '{i18n>TEMPLATE_SENSITIVE}',
...
}
}
event BookOrdered {
...
}
```

> **Important:** Once a template is made `PUBLIC`, it **cannot be reverted to `PRIVATE`**. This is enforced by ANS. Making this a deliberate opt-in ensures that only templates intended for customization are exposed to customer administrators.

### Outbox

In production mode, the plugin uses the CAP persistent ordered outbox to send notifications. This ensures:
Expand Down
7 changes: 3 additions & 4 deletions cds-feature-notifications/pom.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<?xml version="1.0" encoding="UTF-8" standalone="no"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
Expand Down Expand Up @@ -172,7 +171,7 @@
<phase>generate-sources</phase>
<configuration>
<commands>
<command>compile ${project.basedir}/srv/external/*.cds ${project.basedir}/src/main/resources/cds/com.sap.cds/cds-feature-notifications/types.cds --to csn --dest ${project.build.directory}/cds-output/all.csn</command>
<command>compile ${project.basedir}/srv/external/*.cds --to csn --dest ${project.build.directory}/cds-output/all.csn</command>
</commands>
</configuration>
</execution>
Expand Down Expand Up @@ -215,4 +214,4 @@
</plugins>
</build>

</project>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@

import cds.gen.notificationproviderservice.NotificationProviderService;
import cds.gen.notificationproviderservice.NotificationProviderService_;
import cds.gen.notificationtemplateproviderservice.NotificationTemplateProviderService;
import cds.gen.notificationtemplateproviderservice.NotificationTemplateProviderService_;
import cds.gen.notificationtypeproviderservice.NotificationTypeProviderService;
import cds.gen.notificationtypeproviderservice.NotificationTypeProviderService_;
import com.sap.cds.notifications.handlers.EntityNotificationHandler;
import com.sap.cds.notifications.handlers.LocalHandler;
import com.sap.cds.notifications.handlers.LocalNotificationTemplateAutoProvisionerHandler;
import com.sap.cds.notifications.handlers.LocalNotificationTypeAutoProvisionerHandler;
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.services.environment.CdsProperties;
Expand Down Expand Up @@ -72,6 +76,14 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
.getService(
NotificationTypeProviderService.class, NotificationTypeProviderService_.CDS_NAME);

NotificationTemplateProviderService templateProviderSvc =
configurer
.getCdsRuntime()
.getServiceCatalog()
.getService(
NotificationTemplateProviderService.class,
NotificationTemplateProviderService_.CDS_NAME);

NotificationProviderService outboxedSvc;
if (outbox != null) {
outboxedSvc = outbox.outboxed(providerSvc);
Expand All @@ -87,13 +99,20 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
if (environment.getProduction().isEnabled()) {
logger.info("Production mode enabled - using ProductionHandler");
configurer.eventHandler(new ProductionHandler(outboxedSvc, configurer.getCdsRuntime()));
// Register handler for auto-provisioning on application prepared event
// Register handler for auto-provisioning standalone templates on application prepared event
configurer.eventHandler(
new NotificationTemplateAutoProvisionerHandler(
configurer.getCdsRuntime(), templateProviderSvc));
// Register handler for auto-provisioning notification types on application prepared event
configurer.eventHandler(
new NotificationTypeAutoProvisionerHandler(configurer.getCdsRuntime(), typeProviderSvc));
} else {
logger.info("Local mode enabled - using LocalHandler (notifications will be logged only)");
configurer.eventHandler(new LocalHandler(configurer.getCdsRuntime()));
// Register local handler for auto-provisioning (logging only, not sending to ANS)
// Register local handler for auto-provisioning standalone templates (logging only)
configurer.eventHandler(
new LocalNotificationTemplateAutoProvisionerHandler(configurer.getCdsRuntime()));
// Register local handler for auto-provisioning notification types (logging only)
configurer.eventHandler(
new LocalNotificationTypeAutoProvisionerHandler(configurer.getCdsRuntime()));
}
Expand Down Expand Up @@ -129,10 +148,6 @@ public void environment(CdsRuntimeConfigurer configurer) {
.onBehalfOf(OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT)
.build());

System.out.println("http destination is " + httpDestination);
System.out.println("http destination url is " + httpDestination.getUri());
System.out.println("http destination name value is" + httpDestination.get("name").get());

// Add the destination to CDS runtime so RemoteServiceConfig can use it
DestinationAccessor.prependDestinationLoader(
new DefaultDestinationLoader()
Expand Down Expand Up @@ -186,5 +201,27 @@ public void environment(CdsRuntimeConfigurer configurer) {
.getRemote()
.getServices()
.put("NotificationTypeProviderService", notificationTypeConfig);

// Define the remote service for notification templates in CDS runtime that uses the
// SAP_Notifications destination
RemoteServiceConfig notificationTemplateConfig = new RemoteServiceConfig();

notificationTemplateConfig.setType("odata-v2");

notificationTemplateConfig.getDestination().setName("SAP_Notifications");

notificationTemplateConfig.getHttp().setSuffix("/odatav2");
notificationTemplateConfig.getHttp().setService("NotificationTemplate.svc");
notificationTemplateConfig.getHttp().getCsrf().setEnabled(true);

// Register the remote service in CDS runtime environment under the name
// "NotificationTemplateProviderService"
configurer
.getCdsRuntime()
.getEnvironment()
.getCdsProperties()
.getRemote()
.getServices()
.put("NotificationTemplateProviderService", notificationTemplateConfig);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors.
*/
package com.sap.cds.notifications.handlers;
package com.sap.cds.notifications.assemblers;

import cds.gen.notificationproviderservice.NotificationProperties;
import cds.gen.notificationproviderservice.Notifications;
Expand Down Expand Up @@ -45,16 +45,16 @@
* Helper class to build notification objects from event context. Reduces code duplication between
* ProductionHandler and LocalHandler.
*/
public class NotificationBuilder {
public class NotificationAssembler {

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

/** Valid priority values as defined by the SAP Alert Notification service. */
private static final Set<String> VALID_PRIORITIES = Set.of("LOW", "NEUTRAL", "MEDIUM", "HIGH");

private final CdsRuntime runtime;

public NotificationBuilder(CdsRuntime runtime) {
public NotificationAssembler(CdsRuntime runtime) {
this.runtime = runtime;
}

Expand Down Expand Up @@ -110,7 +110,7 @@ public List<NotificationBuildResult> buildNotifications(EventContext context) {
"Batch notification emit for event '{}' with empty data list, skipping", eventName);
return List.of();
}
logger.info("Batch notification emit for event '{}': {} entries", eventName, dataList.size());
logger.debug("Batch notification emit for event '{}': {} entries", eventName, dataList.size());
List<NotificationBuildResult> results = new ArrayList<>();
for (Object item : dataList) {
if (!(item instanceof CdsData cdsData)) {
Expand Down Expand Up @@ -155,6 +155,7 @@ private NotificationBuildResult buildSingleNotification(
Notifications notification = Struct.create(Notifications.class);
notification.setNotificationTypeKey(notificationTypeKey);
notification.setNotificationTypeVersion("1");
notification.setNotificationTemplateKey(notificationTypeKey);
notification.setPriority(priority);
notification.setRecipients(recipientsList);

Expand All @@ -165,7 +166,7 @@ private NotificationBuildResult buildSingleNotification(
List<NotificationProperties> properties = extractProperties(event, eventData);
notification.setProperties(properties);

logger.info("Built notification with {} properties", properties.size());
logger.debug("Built notification with {} properties", properties.size());

return new NotificationBuildResult(eventName, notification, event);
}
Expand Down Expand Up @@ -250,7 +251,7 @@ public CqnPredicate containment(
.map(row -> row.get("result"))
.map(v -> v.toString().toUpperCase())
.orElse("NEUTRAL");
logger.info("Dynamic priority evaluated via DB to: {}", priority);
logger.debug("Dynamic priority evaluated via DB to: {}", priority);
return validatePriority(priority);
} catch (Exception e) {
logger.error(
Expand All @@ -273,7 +274,7 @@ public CqnPredicate containment(
* @param serviceCatalog service catalog to obtain the persistence service
* @return query result containing a single row with column "result"
*/
static Result executeDummySelect(CqnValue resolvedExpression, ServiceCatalog serviceCatalog) {
public static Result executeDummySelect(CqnValue resolvedExpression, ServiceCatalog serviceCatalog) {
Value<?> expr = ExpressionBuilder.create(resolvedExpression).value();
PersistenceService ps =
serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME);
Expand All @@ -297,7 +298,7 @@ static Result executeDummySelect(CqnValue resolvedExpression, ServiceCatalog ser
* be null if both {@code value} and {@code term} are guaranteed to be literals
* @return a tautology ({@code 1=1}) or contradiction ({@code 1=0}) predicate
*/
static CqnPredicate evaluateContainment(
public static CqnPredicate evaluateContainment(
CqnContainmentTest.Position position,
CqnValue value,
CqnValue term,
Expand Down Expand Up @@ -399,7 +400,7 @@ private List<Recipients> resolveRecipients(String eventName, CdsData eventData)
recipientsList.addAll(
list.stream()
.map(String.class::cast)
.map(NotificationBuilder::createRecipientFromId)
.map(NotificationAssembler::createRecipientFromId)
.toList());
} else if (recipientsObj instanceof String) {
String recipientsStr = ((String) recipientsObj).trim();
Expand Down Expand Up @@ -430,7 +431,7 @@ private List<Recipients> resolveRecipients(String eventName, CdsData eventData)

// Auto-detects UUID vs email and maps to GlobalUserId or RecipientId accordingly
@VisibleForTesting
static Recipients createRecipientFromId(String recipientId) {
public static Recipients createRecipientFromId(String recipientId) {
Recipients recipient = Struct.create(Recipients.class);
if (isUUID(recipientId)) {
recipient.setGlobalUserId(recipientId);
Expand All @@ -448,7 +449,7 @@ static Recipients createRecipientFromId(String recipientId) {
}

@VisibleForTesting
static boolean isUUID(String value) {
public static boolean isUUID(String value) {
try {
UUID.fromString(value);
return true;
Expand All @@ -463,7 +464,7 @@ static boolean isUUID(String value) {
Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");

@VisibleForTesting
static boolean isEmail(String value) {
public static boolean isEmail(String value) {
return EMAIL_PATTERN.matcher(value).matches();
}

Expand Down
Loading
Loading