Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6943929
fix: use null-safe check for production mode to default to local mode
busehalis-sap Jun 23, 2026
2090b0d
feat: filter locales per event to exclude framework-only locales and …
busehalis-sap Jun 23, 2026
842a0b6
refactor: rename NotificationBuilderTest to NotificationAssemblerTest…
busehalis-sap Jun 23, 2026
938ffb9
refactor: replace System.out.println with logger.info in LocalNotific…
busehalis-sap Jun 23, 2026
0119525
feat: add Translations to NotificationType payload with required fiel…
busehalis-sap Jun 23, 2026
de22798
refactor: replace UPDATE with delete+create strategy for Notification…
busehalis-sap Jun 23, 2026
7b39260
docs: update CHANGELOG with ANS spec compliance changes and missing e…
busehalis-sap Jun 23, 2026
036b2c2
chore: apply spotless formatting
busehalis-sap Jun 23, 2026
f96ffca
test: add unit tests for NotificationTypeAssembler
busehalis-sap Jun 23, 2026
3218fc5
chore: apply spotless formatting to NotificationTypeAssemblerTest
busehalis-sap Jun 23, 2026
29d3208
fix: use @notification.template.publicTitle for DisplayName in Notifi…
busehalis-sap Jun 24, 2026
50f751f
chore: apply spotless formatting
busehalis-sap Jun 24, 2026
8590d10
refactor: replace delete+create with PUT update strategy for Notifica…
busehalis-sap Jun 24, 2026
1a0b71c
Revert "refactor: replace delete+create with PUT update strategy for …
busehalis-sap Jun 24, 2026
a98815b
docs: update CHANGELOG to remove implementation details
busehalis-sap Jun 24, 2026
b991a77
refactor: update LocalNotificationTypeAutoProvisionerHandler to log T…
busehalis-sap Jun 24, 2026
f6e714d
fix: remove DisplayName from NotificationTemplate translation as it i…
busehalis-sap Jun 24, 2026
669a921
refactor: improve local mode logging for NotificationTemplate provisi…
busehalis-sap Jun 24, 2026
e32fc1e
chore: apply spotless formatting
busehalis-sap Jun 24, 2026
5b76e02
test: remove DisplayName assertion from NotificationTemplateProvision…
busehalis-sap Jun 24, 2026
48f09f3
refactor: improve local mode logging for LocalHandler with box-style …
busehalis-sap Jun 25, 2026
9688fbd
chore: apply spotless formatting
busehalis-sap Jun 25, 2026
7a6ebe3
fix: map Locale.ROOT ('und'=undefined) to 'en' in getAvailableLocales…
busehalis-sap Jun 25, 2026
26d45f3
fix: improve 400 error message to mention ANS field length limits
busehalis-sap Jun 25, 2026
2f0d052
refactor: switch NotificationType re-provisioning from delete+create …
busehalis-sap Jun 26, 2026
939ea29
test: update NotificationTypeProvisioningTest to verify PATCH keeps I…
busehalis-sap Jun 26, 2026
3fd9463
fix: prevent infinite loop in NotificationTemplate re-provisioning by…
busehalis-sap Jun 26, 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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
- CAP Java plugin for integrating SAP Alert Notification Service (ANS) with CAP Java applications
- `@notification` annotation on CDS events to define notification types and standalone notification templates
- Support for email templates via `template.email.subject` and `template.email.html` fields
- Mustache syntax (`{{variableName}}`) for dynamic content substitution in templates
- Mustache syntax (`{{variableName}}`) for dynamic content substitution in notification templates and notification type translations
- i18n support via `{i18n>KEY}` placeholders in all template fields with automatic language detection per recipient
- Automatic notification type provisioning to ANS at application startup (`NotificationTypeAutoProvisionerHandler`)
- Automatic notification type provisioning to ANS at application startup
- `NotificationTemplate` visibility defaults to `PRIVATE` when `@notification.customizable` annotation is absent
- Local mode operation: logs notifications to console without requiring an ANS service binding (`LocalHandler`)
- Production mode operation: sends notifications to SAP Alert Notification Service (`ProductionHandler`)
- Mode toggled via `cds.environment.production.enabled` configuration property
- Mode toggled via `cds.environment.production.enabled` configuration property; defaults to local mode when property is not set or is `null`
- Programmatic notification emission by injecting the generated `NotificationService` in CAP event handlers
- Declarative notification triggering via `@notifications` annotation on CDS entities for `CREATE`, `UPDATE`, and `DELETE` events
- Support for static and dynamic notification priorities (`#LOW`, `#NEUTRAL`, `#MEDIUM`, `#HIGH`) evaluated via CDS expressions at runtime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
OutboxService.PERSISTENT_ORDERED_NAME);
}

if (environment.getProduction().isEnabled()) {
if (Boolean.TRUE.equals(environment.getProduction().isEnabled())) {
logger.info("Production mode enabled - using ProductionHandler");
configurer.eventHandler(new ProductionHandler(outboxedSvc, configurer.getCdsRuntime()));
// Register handler for auto-provisioning standalone templates on application prepared event
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,9 @@ private Optional<NotificationTemplates> extractTemplateFromEvent(CdsEvent event)
NotificationTemplates template = Struct.create(NotificationTemplates.class);
template.setKey(key);

// Visibility - from @notification.customizable annotation (ANS defaults to PRIVATE)
// @notification.customizable: true → PUBLIC, absent or false → PRIVATE (default)
String visibility = extractVisibility(event);
if (visibility != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check not needed anymore?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value from ANS is PRIVATE when no visibility is set. I updated the extractVisibility method to handle this explicitly: if the @notification.customizable annotation is absent, it now returns "PRIVATE" directly instead of null. So the method always returns either "PUBLIC" or "PRIVATE", making the null check here unnecessary.

template.setVisibility(visibility);
}
// Visibility - from @notification.customizable annotation
// @notification.customizable: true → PUBLIC, absent or false → PRIVATE
template.setVisibility(extractVisibility(event));

// PropertiesSchema - auto-generated from event elements
String propertiesSchema = buildPropertiesSchema(event);
Expand All @@ -90,7 +87,7 @@ private Optional<NotificationTemplates> extractTemplateFromEvent(CdsEvent event)
List<Tags> tags = buildTags(source, eventName);
template.setTags(tags);

Set<Locale> locales = i18nHelper.getAvailableLocales();
Set<Locale> locales = i18nHelper.getAvailableLocalesForEvent(event);
logger.debug("Creating translations for {} discovered i18n locales", locales.size());

List<Translations> translations = new ArrayList<>();
Expand All @@ -116,10 +113,9 @@ private Translations createTranslation(
translation.setLanguage(lang);
translation.setSyntax(DEFAULT_SYNTAX);

// Source, Event, DisplayName - for admin UI filtering and display
// Source, Event - for admin UI filtering and display
translation.setSource(source);
translation.setEvent(eventName);
translation.setDisplayName(eventName);

// Title (required) - from @notification.template.title
String title =
Expand Down Expand Up @@ -203,9 +199,11 @@ private Email buildEmail(CdsEvent event, Map<String, String> i18nTexts) {
}

private String extractVisibility(CdsEvent event) {
return Boolean.TRUE.equals(event.getAnnotationValue("notification.customizable", Boolean.FALSE))
? "PUBLIC"
: null;
var annotation = event.findAnnotation("notification.customizable");
if (annotation.isEmpty()) {
return "PRIVATE"; // absent → explicit PRIVATE (same as ANS default)
}
return Boolean.TRUE.equals(annotation.get().getValue()) ? "PUBLIC" : "PRIVATE";
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import cds.gen.notificationtypeproviderservice.DeliveryChannels;
import cds.gen.notificationtypeproviderservice.NotificationTypes;
import cds.gen.notificationtypeproviderservice.Translations;
import com.sap.cds.Struct;
import com.sap.cds.notifications.helpers.I18nHelper;
import com.sap.cds.reflect.CdsEvent;
import com.sap.cds.reflect.CdsModel;
import com.sap.cds.services.runtime.CdsRuntime;
Expand All @@ -19,9 +21,11 @@ public class NotificationTypeAssembler {
private static final Logger logger = LoggerFactory.getLogger(NotificationTypeAssembler.class);

private final CdsRuntime runtime;
private final I18nHelper i18nHelper;

public NotificationTypeAssembler(CdsRuntime runtime) {
this.runtime = runtime;
this.i18nHelper = new I18nHelper(runtime);
}

/** Build all notification types from CDS model event annotations. */
Expand All @@ -46,16 +50,84 @@ private Optional<NotificationTypes> extractNotificationTypeFromEvent(CdsEvent ev
nt.setNotificationTypeKey(key);
nt.setNotificationTypeVersion("1");

// Extract translations (required by ANS — at least Translations or Templates must be present)
List<Translations> translations = extractTranslations(event);
nt.setTranslations(translations);

// Extract delivery channels
List<DeliveryChannels> deliveryChannels = extractDeliveryChannels(event);
if (!deliveryChannels.isEmpty()) {
nt.setDeliveryChannels(deliveryChannels);
}

logger.debug("Extracted NotificationType: {}", key);
logger.debug("Extracted NotificationType: {} with {} translation(s)", key, translations.size());
return Optional.of(nt);
}

/**
* Extract Translations from CDS event annotations for all available i18n locales.
*
* <p>Mapping:
*
* <ul>
* <li>{@code @notification.template.publicTitle} → DisplayName (non-sensitive, shown in user
* preferences)
* <li>{@code @notification.template.groupedTitle} → GroupTitle (required)
* <li>{@code @description} → Description (optional)
* </ul>
*/
private List<Translations> extractTranslations(CdsEvent event) {
Set<Locale> locales = i18nHelper.getAvailableLocalesForEvent(event);
List<Translations> translations = new ArrayList<>();

for (Locale locale : locales) {
Map<String, String> i18nTexts = i18nHelper.getI18nTexts(locale);

Translations translation = Struct.create(Translations.class);
translation.setLanguage(locale.toLanguageTag());

// DisplayName — from @notification.template.publicTitle (non-sensitive, shown in user
// preferences)
String displayName =
i18nHelper.resolveAnnotationValue(event, "notification.template.publicTitle", i18nTexts);
if (displayName == null || displayName.isBlank()) {
throw new IllegalStateException(
String.format(
"Missing required annotation: @notification.template.publicTitle for event '%s'.",
event.getName()));
}
translation.setDisplayName(displayName);

// GroupTitle — from @notification.template.groupedTitle (required)
String groupTitle =
i18nHelper.resolveAnnotationValue(event, "notification.template.groupedTitle", i18nTexts);
if (groupTitle == null || groupTitle.isBlank()) {
throw new IllegalStateException(
String.format(
"Missing required annotation: @notification.template.groupedTitle for event '%s'.",
event.getName()));
}
translation.setGroupTitle(groupTitle);

translation.setSyntax("MUSTACHE");

// Description — from @description (optional)
String description = i18nHelper.resolveAnnotationValue(event, "description", i18nTexts);
if (description != null && !description.isBlank()) {
translation.setDescription(description);
}

translations.add(translation);
logger.debug(
"Created NotificationType translation: lang={}, displayName={}, groupTitle={}",
locale.toLanguageTag(),
displayName,
groupTitle);
}

return translations;
}

@SuppressWarnings("unchecked")
private List<DeliveryChannels> extractDeliveryChannels(CdsEvent event) {
List<DeliveryChannels> deliveryChannels = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@

import cds.gen.notificationproviderservice.NotificationProperties;
import cds.gen.notificationproviderservice.Notifications;
import cds.gen.notificationproviderservice.Recipients;
import com.sap.cds.notifications.assemblers.NotificationAssembler;
import com.sap.cds.notifications.helpers.I18nHelper;
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;
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.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -22,9 +26,11 @@ public class LocalHandler implements EventHandler {

private static final Logger logger = LoggerFactory.getLogger(LocalHandler.class);
private final NotificationAssembler notificationBuilder;
private final I18nHelper i18nHelper;

public LocalHandler(CdsRuntime runtime) {
this.notificationBuilder = new NotificationAssembler(runtime);
this.i18nHelper = new I18nHelper(runtime);
}

@On(event = "*")
Expand All @@ -35,43 +41,95 @@ public void postNotifications(EventContext context) {
return;
}

String eventName = results.get(0).eventName();
logger.info(
"=== Processing {} notification(s) (LOCAL MODE) for event: {} ===",
results.size(),
eventName);

for (int i = 0; i < results.size(); i++) {
Notifications notification = results.get(i).notification();
NotificationAssembler.NotificationBuildResult result = results.get(i);
Notifications notification = result.notification();
CdsEvent event = result.event();

logger.info("---------------------------------------------------------------");
if (results.size() > 1) {
logger.info("Notification {}/{} (Local Mode - Not Sent to ANS)", i + 1, results.size());
} else {
logger.info("Notification (Local Mode - Not Sent to ANS)");
}
logger.info(" Event: {}", eventName);
logger.info(" NotificationTypeKey: {}", notification.getNotificationTypeKey());
logger.info(" NotificationTypeVersion: {}", notification.getNotificationTypeVersion());
Map<String, String> props =
notification.getProperties().stream()
.collect(
Collectors.toMap(
NotificationProperties::getKey,
p -> p.getValue() != null ? p.getValue() : ""));

String title = renderTemplate(event, "notification.template.title", props);
String subtitle = renderTemplate(event, "notification.template.subtitle", props);
String emailSubject = renderTemplate(event, "notification.template.email.subject", props);
String emailBodyText = renderTemplate(event, "notification.template.email.text", props);
String emailBodyHtml = renderTemplate(event, "notification.template.email.html", props);

String recipientList =
notification.getRecipients().stream()
.map(r -> r.getRecipientId() != null ? r.getRecipientId() : r.getGlobalUserId())
.collect(Collectors.joining(", "));

String priority = notification.getPriority() != null ? notification.getPriority() : "NEUTRAL";

String index = results.size() > 1 ? " (" + (i + 1) + "/" + results.size() + ")" : "";

logger.info("┌──────────────────────────────────────────────────────────────┐");
logger.info("│ LOCAL NOTIFICATION{} (not sent to ANS)", index);
logger.info("├──────────────────────────────────────────────────────────────┤");
logger.info("│ From: noreply@notifications.local");
logger.info("│ To: {}", recipientList);
logger.info(
" Priority: {}",
notification.getPriority() != null ? notification.getPriority() : "NEUTRAL");
logger.info(" Recipients:");
for (Recipients recipient : notification.getRecipients()) {
String recipientInfo =
recipient.getRecipientId() != null
? recipient.getRecipientId()
: "GlobalUserId=" + recipient.getGlobalUserId();
logger.info(" - {}", recipientInfo);
"│ Subject: {}",
emailSubject != null ? emailSubject : (title != null ? title : result.eventName()));
logger.info("│ Priority: {}", priority);
logger.info("├──────────────────────────────────────────────────────────────┤");
if (subtitle != null) {
logger.info("│ {}", subtitle);
}
if (emailBodyText != null || emailBodyHtml != null) {
logger.info("│");
logger.info("│ Email:");
if (emailBodyText != null) {
logger.info("│ Body (text): {}", emailBodyText);
}
if (emailBodyHtml != null) {
String htmlContent =
emailBodyHtml.endsWith(".html")
? i18nHelper.loadHtmlFromClasspath(
emailBodyHtml, i18nHelper.getI18nTexts(Locale.ENGLISH))
: emailBodyHtml;
if (htmlContent != null) {
for (Map.Entry<String, String> entry : props.entrySet()) {
htmlContent = htmlContent.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
int bodyStart = htmlContent.toLowerCase().indexOf("<body");
int bodyEnd = htmlContent.toLowerCase().indexOf("</body>");
String bodyContent =
(bodyStart >= 0 && bodyEnd > bodyStart)
? htmlContent.substring(htmlContent.indexOf('>', bodyStart) + 1, bodyEnd)
: htmlContent;
logger.info(
"│ Body: {}",
bodyContent.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim());
}
}
}
logger.info(" Properties ({}):", notification.getProperties().size());
for (NotificationProperties prop : notification.getProperties()) {
logger.info(" - {}: {}", prop.getKey(), prop.getValue());
logger.info("│");
logger.info("│ Notification Type: {}", result.eventName());
if (!props.isEmpty()) {
logger.info("│ Parameters:");
props.forEach((key, value) -> logger.info("│ - {} = {}", key, value));
}
logger.info("---------------------------------------------------------------");
logger.info("└──────────────────────────────────────────────────────────────┘");
}

logger.info("{} notification(s) logged successfully for event: {}", results.size(), eventName);
context.setCompleted();
}

private String renderTemplate(CdsEvent event, String annotationPath, Map<String, String> props) {
Map<String, String> i18nTexts = i18nHelper.getI18nTexts(Locale.ENGLISH);
String template = i18nHelper.resolveAnnotationValue(event, annotationPath, i18nTexts);
if (template == null) {
return null;
}
for (Map.Entry<String, String> entry : props.entrySet()) {
template = template.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return template;
}
}
Loading
Loading