From 69439297e72dc2478807b507c67c663feaafb947 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:23:09 +0200 Subject: [PATCH 01/27] fix: use null-safe check for production mode to default to local mode --- .../sap/cds/notifications/NotificationServiceConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8219c35..a5ab934 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 @@ -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 From 2090b0df35f56a0cf996d844cc34424789821e8f Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:28:03 +0200 Subject: [PATCH 02/27] feat: filter locales per event to exclude framework-only locales and set explicit PRIVATE visibility when @notification.customizable is absent --- .../NotificationTemplateAssembler.java | 19 ++++++----- .../cds/notifications/helpers/I18nHelper.java | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java index de2f69f..b032ef0 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java @@ -73,12 +73,9 @@ private Optional 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) { - 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); @@ -90,7 +87,7 @@ private Optional extractTemplateFromEvent(CdsEvent event) List tags = buildTags(source, eventName); template.setTags(tags); - Set locales = i18nHelper.getAvailableLocales(); + Set locales = i18nHelper.getAvailableLocalesForEvent(event); logger.debug("Creating translations for {} discovered i18n locales", locales.size()); List translations = new ArrayList<>(); @@ -203,9 +200,11 @@ private Email buildEmail(CdsEvent event, Map 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"; } /** diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java index ab707a7..08722ad 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java @@ -54,6 +54,38 @@ public Set getAvailableLocales() { return Set.of(Locale.ENGLISH); } + /** + * Get locales that have actual notification translations for the given event. Compares each + * locale's resolved {@code notification.template.title} against the English value. If they + * differ, the locale has a real translation and is included. English is always included. + * + *

This filters out locales that only exist because of {@code @sap/cds/common} framework + * translations (e.g. "Created By" in 37 languages) but have no app-specific notification texts. + */ + public Set getAvailableLocalesForEvent(CdsEvent event) { + Set allLocales = getAvailableLocales(); + Map enTexts = getI18nTexts(Locale.ENGLISH); + String enTitle = resolveAnnotationValue(event, "notification.template.title", enTexts); + + Set filtered = new LinkedHashSet<>(); + for (Locale locale : allLocales) { + // Always keep root ("und" = fallback for unknown locales) and English + if (locale.getLanguage().isEmpty() || "en".equals(locale.getLanguage())) { + filtered.add(locale); + continue; + } + Map localeTexts = getI18nTexts(locale); + String localeTitle = resolveAnnotationValue(event, "notification.template.title", localeTexts); + if (!Objects.equals(enTitle, localeTitle)) { + filtered.add(locale); + } + } + + logger.debug("Filtered locales for '{}': {} (skipped {} without notification translations)", + event.getName(), filtered, allLocales.size() - filtered.size()); + return filtered; + } + /** * Get i18n texts for a given locale from the EdmxI18nProvider. The provider reads from * edmx/_i18n/i18n.json which is generated by the CDS compiler from _i18n/i18n*.properties files From 842a0b6644c04914a1480d78254af0102bfbdf6f Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:29:49 +0200 Subject: [PATCH 03/27] refactor: rename NotificationBuilderTest to NotificationAssemblerTest and move to assemblers package --- .../NotificationAssemblerTest.java} | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) rename cds-feature-notifications/src/test/java/com/sap/cds/notifications/{builders/NotificationBuilderTest.java => assemblers/NotificationAssemblerTest.java} (98%) diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/builders/NotificationBuilderTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationAssemblerTest.java similarity index 98% rename from cds-feature-notifications/src/test/java/com/sap/cds/notifications/builders/NotificationBuilderTest.java rename to cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationAssemblerTest.java index 32dc04f..9d40c60 100644 --- a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/builders/NotificationBuilderTest.java +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationAssemblerTest.java @@ -1,12 +1,11 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. */ -package com.sap.cds.notifications.builders; +package com.sap.cds.notifications.assemblers; import static org.junit.jupiter.api.Assertions.*; import cds.gen.notificationproviderservice.Recipients; -import com.sap.cds.notifications.assemblers.NotificationAssembler; import com.sap.cds.ql.CQL; import com.sap.cds.ql.cqn.CqnComparisonPredicate; import com.sap.cds.ql.cqn.CqnContainmentTest; @@ -22,7 +21,7 @@ * Unit tests for recipient auto-detection logic in {@link NotificationAssembler}. Covers isUUID, * isEmail, and createRecipientFromId including edge cases (malformed, null, empty). */ -class NotificationBuilderTest { +class NotificationAssemblerTest { // ── isUUID ──────────────────────────────────────────────────────────────── From 938ffb914c503318a27a65b977193e261ba34916 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:30:47 +0200 Subject: [PATCH 04/27] refactor: replace System.out.println with logger.info in LocalNotificationTypeAutoProvisionerHandler --- ...otificationTypeAutoProvisionerHandler.java | 66 ++++++------------- 1 file changed, 20 insertions(+), 46 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java index cf67bce..b396c48 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java @@ -49,62 +49,36 @@ private void provisionNotificationTypes() { } private void logNotificationType(NotificationTypes notificationType) { - // Log to console instead of sending to ANS - System.out.println("\n==============================================================="); - System.out.println("NotificationType (Local Mode - Not Sent to ANS)"); - System.out.println(" Key: " + notificationType.getNotificationTypeKey()); - System.out.println(" Version: " + notificationType.getNotificationTypeVersion()); - System.out.println( - " Templates (" - + (notificationType.getTemplates() != null ? notificationType.getTemplates().size() : 0) - + "):"); + logger.info("┌──────────────────────────────────────────────────────────────┐"); + logger.info("│ NotificationType (Local Mode - Not Sent to ANS)"); + logger.info("│ Key: {}", notificationType.getNotificationTypeKey()); + logger.info("│ Version: {}", notificationType.getNotificationTypeVersion()); + logger.info("│ Templates ({}):", + notificationType.getTemplates() != null ? notificationType.getTemplates().size() : 0); if (notificationType.getTemplates() != null) { for (Templates template : notificationType.getTemplates()) { - System.out.println( - " - Language: " - + template.getLanguage() - + "\n" - + " Public Title: " - + template.getTemplatePublic() - + "\n" - + " Sensitive Title: " - + template.getTemplateSensitive() - + "\n" - + " Grouped Title: " - + template.getTemplateGrouped() - + "\n" - + " Subtitle: " - + template.getSubtitle() - + "\n" - + " Email Subject: " - + template.getEmailSubject() - + "\n" - + " Email HTML: " - + (template.getEmailHtml() != null - ? template - .getEmailHtml() - .substring(0, Math.min(100, template.getEmailHtml().length())) - + "..." - : "null")); + logger.info("│ - Language: {}", template.getLanguage()); + logger.info("│ Public Title: {}", template.getTemplatePublic()); + logger.info("│ Sensitive Title: {}", template.getTemplateSensitive()); + logger.info("│ Grouped Title: {}", template.getTemplateGrouped()); + logger.info("│ Subtitle: {}", template.getSubtitle()); + logger.info("│ Email Subject: {}", template.getEmailSubject()); + if (template.getEmailHtml() != null) { + String preview = template.getEmailHtml().substring(0, Math.min(100, template.getEmailHtml().length())) + "..."; + logger.info("│ Email HTML: {}", preview); + } } } if (notificationType.getDeliveryChannels() != null) { - System.out.println( - " Delivery Channels (" + notificationType.getDeliveryChannels().size() + "):"); + logger.info("│ Delivery Channels ({}):", notificationType.getDeliveryChannels().size()); for (DeliveryChannels channel : notificationType.getDeliveryChannels()) { - System.out.println( - " - Type: " - + channel.getType() - + ", Enabled: " - + channel.getEnabled() - + ", DefaultPreference: " - + channel.getDefaultPreference()); + logger.info("│ - Type: {}, Enabled: {}, DefaultPreference: {}", + channel.getType(), channel.getEnabled(), channel.getDefaultPreference()); } } - - System.out.println("===============================================================\n"); + logger.info("└──────────────────────────────────────────────────────────────┘"); logger.info( "NotificationType '{}' logged (LOCAL MODE - not sent to ANS)", From 01195252c14d4052655a57a1a16382a63329ff91 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:33:10 +0200 Subject: [PATCH 05/27] feat: add Translations to NotificationType payload with required field validation and ANS spec compliance --- .../assemblers/NotificationTypeAssembler.java | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java index ce4e19a..1b3bb7b 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java @@ -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; @@ -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. */ @@ -46,16 +50,89 @@ private Optional extractNotificationTypeFromEvent(CdsEvent ev nt.setNotificationTypeKey(key); nt.setNotificationTypeVersion("1"); + // Extract translations (required by ANS — at least Translations or Templates must be present) + List translations = extractTranslations(event); + nt.setTranslations(translations); + // Extract delivery channels List 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); } + /** ANS Translation field constraints (per ANS API spec). */ + private static final int MAX_LANGUAGE_LENGTH = 20; + private static final int MAX_TEXT_LENGTH = 256; + + /** + * Extract Translations from CDS event annotations for all available i18n locales. + * + *

Mapping: + *

    + *
  • {@code @notification.template.title} → DisplayName (required) + *
  • {@code @notification.template.groupedTitle} → GroupTitle (required) + *
  • {@code @description} → Description (optional) + *
+ */ + private List extractTranslations(CdsEvent event) { + Set locales = i18nHelper.getAvailableLocalesForEvent(event); + List translations = new ArrayList<>(); + + for (Locale locale : locales) { + Map i18nTexts = i18nHelper.getI18nTexts(locale); + + Translations translation = Struct.create(Translations.class); + translation.setLanguage(truncate(locale.toLanguageTag(), MAX_LANGUAGE_LENGTH)); + + // DisplayName — from @notification.template.title + String displayName = + i18nHelper.resolveAnnotationValue(event, "notification.template.title", i18nTexts); + if (displayName == null || displayName.isBlank()) { + displayName = event.getName(); + } + translation.setDisplayName(truncate(displayName, MAX_TEXT_LENGTH)); + + // 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(truncate(groupTitle, MAX_TEXT_LENGTH)); + + translation.setSyntax("MUSTACHE"); + + // Description — from @description (optional) + String description = i18nHelper.resolveAnnotationValue(event, "description", i18nTexts); + if (description != null && !description.isBlank()) { + translation.setDescription(truncate(description, MAX_TEXT_LENGTH)); + } + + translations.add(translation); + logger.debug( + "Created NotificationType translation: lang={}, displayName={}, groupTitle={}", + locale.toLanguageTag(), + displayName, + groupTitle); + } + + return translations; + } + + private static String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + @SuppressWarnings("unchecked") private List extractDeliveryChannels(CdsEvent event) { List deliveryChannels = new ArrayList<>(); From de227987801d20311b92106d54dd4ffb463ba1fe Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:48:30 +0200 Subject: [PATCH 06/27] refactor: replace UPDATE with delete+create strategy for NotificationType re-provisioning to avoid ANS 400 rejection when existing type has Templates but new payload uses Translations, and align NotificationTemplate re-provisioning to use the same strategy for consistency --- ...icationTemplateAutoProvisionerHandler.java | 31 ++++-------- ...otificationTypeAutoProvisionerHandler.java | 42 ++++++---------- ...ionTemplateProviderServiceMockHandler.java | 30 +++++++++++ ...icationTypeProviderServiceMockHandler.java | 35 +++++++++++++ .../NotificationTemplateProvisioningTest.java | 32 ++++++------ .../NotificationTypeProvisioningTest.java | 50 ++++++++++--------- 6 files changed, 131 insertions(+), 89 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java index 9e1ff12..baa598d 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java @@ -7,9 +7,9 @@ import cds.gen.notificationtemplateproviderservice.NotificationTemplates; import cds.gen.notificationtemplateproviderservice.NotificationTemplates_; import com.sap.cds.notifications.assemblers.NotificationTemplateAssembler; +import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.ql.Update; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; @@ -133,31 +133,20 @@ private void createTemplate(NotificationTemplates template) { } private void updateTemplate(NotificationTemplates template) { - logger.debug("Updating standalone template '{}'", template.getKey()); + logger.debug("Updating standalone template '{}' (delete + re-create)", template.getKey()); try { notificationTemplateProviderService.run( - Update.entity(NotificationTemplates_.CDS_NAME).data(template)); + Delete.from(NotificationTemplates_.class).where(nt -> nt.Key().eq(template.getKey()))); - logger.debug( - "Standalone NotificationTemplate '{}' updated in ANS successfully", template.getKey()); + logger.debug("Deleted existing standalone template '{}'", template.getKey()); } catch (Exception e) { - String errorMsg = e.getMessage() != null ? e.getMessage() : ""; - - if (errorMsg.contains("400")) { - logger.error( - "ANS rejected standalone template update '{}' with 400 Bad Request. Error: {}", - template.getKey(), - errorMsg); - throw new IllegalStateException( - String.format( - "ANS rejected standalone template update '%s' with 400 Bad Request. Error: %s", - template.getKey(), errorMsg), - e); - } - - logger.error("Failed to update standalone template '{}' in ANS", template.getKey(), e); - throw e; + logger.warn( + "Could not delete existing standalone template '{}': {}", + template.getKey(), + e.getMessage()); } + + createTemplate(template); } } diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java index 52bffab..a14076f 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java @@ -7,9 +7,9 @@ import cds.gen.notificationtypeproviderservice.NotificationTypes; import cds.gen.notificationtypeproviderservice.NotificationTypes_; import com.sap.cds.notifications.assemblers.NotificationTypeAssembler; +import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.ql.Update; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; @@ -160,44 +160,30 @@ private void createNotificationType(NotificationTypes notificationType) { private void updateNotificationType( NotificationTypes notificationType, String notificationTypeId) { - notificationType.setNotificationTypeId(notificationTypeId); logger.debug( - "Updating NotificationType '{}' (id={})", + "Updating NotificationType '{}' (id={}) via delete+create", notificationType.getNotificationTypeKey(), notificationTypeId); try { + // ANS does not allow mixing Translations and Templates on update, + // so we delete the existing type and recreate it cleanly. notificationTypeProviderService.run( - Update.entity(NotificationTypes_.CDS_NAME).data(notificationType)); + Delete.from(NotificationTypes_.class) + .where(nt -> nt.NotificationTypeId().eq(notificationTypeId))); logger.debug( - "NotificationType '{}' updated in ANS successfully", - notificationType.getNotificationTypeKey()); + "Deleted existing NotificationType '{}' (id={})", + notificationType.getNotificationTypeKey(), + notificationTypeId); } catch (Exception e) { - String errorMsg = e.getMessage() != null ? e.getMessage() : ""; - - if (errorMsg.contains("400")) { - logger.error( - "ANS rejected NotificationType update '{}' with 400 Bad Request. " - + "This usually means required fields are missing or invalid. " - + "Check that all required fields (publicTitle, title, groupedTitle, subtitle) are set. " - + "Error: {}", - notificationType.getNotificationTypeKey(), - errorMsg); - throw new IllegalStateException( - String.format( - "ANS rejected NotificationType update '%s' with 400 Bad Request. " - + "Ensure all required template fields are properly configured in your CDS model and i18n files. Error: %s", - notificationType.getNotificationTypeKey(), errorMsg), - e); - } - - logger.error( - "Failed to update NotificationType '{}' (id={})", + logger.warn( + "Failed to delete existing NotificationType '{}' (id={}): {}. Attempting create anyway.", notificationType.getNotificationTypeKey(), notificationTypeId, - e); - throw e; + e.getMessage()); } + + createNotificationType(notificationType); } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java index 6e3ba8c..8f65a30 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java +++ b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java @@ -6,6 +6,7 @@ import cds.gen.notificationtemplateproviderservice.NotificationTemplates; import cds.gen.notificationtemplateproviderservice.NotificationTemplates_; import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; import com.sap.cds.services.cds.CqnService; @@ -38,6 +39,9 @@ public class NotificationTemplateProviderServiceMockHandler implements EventHand // Tracks how many times each template key has been updated private static final Map updateCountByKey = new ConcurrentHashMap<>(); + // Tracks how many times each template key has been deleted + private static final Map deleteCountByKey = new ConcurrentHashMap<>(); + @On(event = CqnService.EVENT_CREATE, entity = NotificationTemplates_.CDS_NAME) public void interceptCreate(CdsCreateEventContext context) { logger.debug( @@ -76,6 +80,26 @@ public void interceptCreate(CdsCreateEventContext context) { context.setCompleted(); } + @On(event = CqnService.EVENT_DELETE, entity = NotificationTemplates_.CDS_NAME) + public void interceptDelete(CdsDeleteEventContext context) { + logger.debug("MockHandler intercepting NotificationTemplates DELETE"); + + context.getCqn().where().ifPresent(where -> { + String whereStr = where.toString(); + templateStore.entrySet().removeIf(entry -> { + boolean matches = whereStr.contains(entry.getKey()); + if (matches) { + deleteCountByKey.computeIfAbsent(entry.getKey(), k -> new AtomicInteger(0)).incrementAndGet(); + logger.debug("MockHandler deleted notification template: Key={}", entry.getKey()); + } + return matches; + }); + }); + + context.setResult(Collections.emptyList()); + context.setCompleted(); + } + @On(event = CqnService.EVENT_READ, entity = NotificationTemplates_.CDS_NAME) public void interceptRead(CdsReadEventContext context) { logger.debug("MockHandler intercepting NotificationTemplates READ"); @@ -145,6 +169,7 @@ public static List getAllTemplates() { public static void clearAllTemplates() { templateStore.clear(); updateCountByKey.clear(); + deleteCountByKey.clear(); logger.debug("Mock NotificationTemplateProviderService: Cleared all templates"); } @@ -177,4 +202,9 @@ public static int getUpdateCount(String key) { AtomicInteger count = updateCountByKey.get(key); return count != null ? count.get() : 0; } + + public static int getDeleteCount(String key) { + AtomicInteger count = deleteCountByKey.get(key); + return count != null ? count.get() : 0; + } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java index c6a9cda..c0adac8 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java +++ b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java @@ -6,6 +6,7 @@ import cds.gen.notificationtypeproviderservice.NotificationTypes; import cds.gen.notificationtypeproviderservice.NotificationTypes_; import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; import com.sap.cds.services.cds.CqnService; @@ -43,6 +44,9 @@ public class NotificationTypeProviderServiceMockHandler implements EventHandler // Tracks how many times each NotificationTypeKey has been updated private static final Map updateCountByKey = new ConcurrentHashMap<>(); + // Tracks how many times each NotificationTypeKey has been deleted + private static final Map deleteCountByKey = new ConcurrentHashMap<>(); + @On(event = CqnService.EVENT_CREATE, entity = NotificationTypes_.CDS_NAME) public void interceptCreate(CdsCreateEventContext context) { logger.debug( @@ -93,6 +97,31 @@ public void interceptCreate(CdsCreateEventContext context) { context.setCompleted(); } + @On(event = CqnService.EVENT_DELETE, entity = NotificationTypes_.CDS_NAME) + public void interceptDelete(CdsDeleteEventContext context) { + logger.debug("MockHandler intercepting NotificationTypes DELETE"); + + context.getCqn().where().ifPresent(where -> { + // Extract the ID from the WHERE clause and delete from store + notificationTypeStore.entrySet().removeIf(entry -> { + NotificationTypes nt = entry.getValue(); + String id = nt.getNotificationTypeId(); + boolean matches = where.toString().contains(id != null ? id : ""); + if (matches) { + String key = nt.getNotificationTypeKey(); + String keyVersion = key + ":" + nt.getNotificationTypeVersion(); + notificationTypeByKeyVersion.remove(keyVersion); + deleteCountByKey.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet(); + logger.debug("MockHandler deleted notification type: Key={}, ID={}", key, id); + } + return matches; + }); + }); + + context.setResult(Collections.emptyList()); + context.setCompleted(); + } + @On(event = CqnService.EVENT_READ, entity = NotificationTypes_.CDS_NAME) public void interceptRead(CdsReadEventContext context) { logger.debug("MockHandler intercepting NotificationTypes READ"); @@ -180,6 +209,7 @@ public static void clearAllNotificationTypes() { notificationTypeStore.clear(); notificationTypeByKeyVersion.clear(); updateCountByKey.clear(); + deleteCountByKey.clear(); logger.debug("Mock NotificationTypeProviderService: Cleared all notification types"); } @@ -226,6 +256,11 @@ public static int getUpdateCount(String notificationTypeKey) { return count != null ? count.get() : 0; } + public static int getDeleteCount(String notificationTypeKey) { + AtomicInteger count = deleteCountByKey.get(notificationTypeKey); + return count != null ? count.get() : 0; + } + /** Gets the total number of updates across all notification types. */ public static int getTotalUpdateCount() { return updateCountByKey.values().stream().mapToInt(AtomicInteger::get).sum(); diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java index 26d2ced..12b55c1 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java @@ -115,27 +115,27 @@ void testCertificateExpirationTemplateStructure() { } // ────────────────────────────────────────────────────────────── - // Test 3: Visibility defaults to PRIVATE (not set) when no annotation + // Test 3: Visibility defaults to PRIVATE when no annotation // ────────────────────────────────────────────────────────────── @Test void testVisibilityDefaultsToPrivate() { LOG.debug("=========================================="); LOG.debug( - "Test: Templates without @notification.customizable should have null visibility (PRIVATE)"); + "Test: Templates without @notification.customizable should have PRIVATE visibility"); LOG.debug("=========================================="); - // SystemMaintenance has no @notification.customizable → visibility should be null (ANS defaults - // PRIVATE) + // SystemMaintenance has no @notification.customizable → visibility should be PRIVATE NotificationTemplates template = NotificationTemplateProviderServiceMockHandler.getTemplateByKey("SystemMaintenance"); assertNotNull(template, "SystemMaintenance template should be provisioned"); - assertNull( + assertEquals( + "PRIVATE", template.getVisibility(), - "Template without @notification.customizable should have null visibility (ANS defaults to PRIVATE)"); + "Template without @notification.customizable should have PRIVATE visibility"); LOG.debug( - "SystemMaintenance visibility: {} (null = ANS default PRIVATE)", template.getVisibility()); + "SystemMaintenance visibility: {}", template.getVisibility()); } // ────────────────────────────────────────────────────────────── @@ -555,29 +555,29 @@ private void assertNoUnresolvedI18n(String value, String fieldName, String lang) @Test void testReProvisioningUpdatesExistingTemplates() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should UPDATE existing templates"); + LOG.debug("Test: Re-provisioning should DELETE and recreate existing templates"); LOG.debug("=========================================="); int countBefore = NotificationTemplateProviderServiceMockHandler.getTemplateCount(); assertTrue(countBefore > 0, "Templates should already be provisioned at startup"); - int updatesBefore = - NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); + int deletesBefore = + NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); createProvisioner().onApplicationPrepared(); - int updatesAfter = - NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); + int deletesAfter = + NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); assertEquals( - updatesBefore + 1, - updatesAfter, - "CertificateExpiration template should have been updated once during re-provisioning"); + deletesBefore + 1, + deletesAfter, + "CertificateExpiration template should have been deleted once during re-provisioning"); assertEquals( countBefore, NotificationTemplateProviderServiceMockHandler.getTemplateCount(), "Template count should remain the same after re-provisioning"); - LOG.debug("Re-provisioning triggered UPDATE for existing templates"); + LOG.debug("Re-provisioning triggered DELETE+CREATE for existing templates"); } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java index cf1e961..e2a98d8 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java @@ -86,73 +86,75 @@ void testEachNotificationTypeHasUniqueId() { } // ────────────────────────────────────────────────────────────── - // Test 2: Re-provisioning keeps IDs stable (UPDATE, not INSERT) + // Test 2: Re-provisioning uses delete+create (new IDs assigned) // ────────────────────────────────────────────────────────────── @Test void testReProvisioningUpdatesEachTypeCorrectly() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should update each type without changing IDs"); + LOG.debug("Test: Re-provisioning should delete and recreate each type"); LOG.debug("=========================================="); - Map idsBefore = new HashMap<>(); - for (NotificationTypes nt : - NotificationTypeProviderServiceMockHandler.getAllNotificationTypes()) { - idsBefore.put(nt.getNotificationTypeKey(), nt.getNotificationTypeId()); + Map deletesBefore = new HashMap<>(); + for (String key : EXPECTED_KEYS) { + deletesBefore.put(key, NotificationTypeProviderServiceMockHandler.getDeleteCount(key)); } assertEquals( - EXPECTED_KEYS.size(), idsBefore.size(), "All types should exist before re-provisioning"); + EXPECTED_KEYS.size(), NotificationTypeProviderServiceMockHandler.getNotificationTypeCount(), + "All types should exist before re-provisioning"); createProvisioner().onApplicationPrepared(); - Map idsAfter = new HashMap<>(); - for (NotificationTypes nt : - NotificationTypeProviderServiceMockHandler.getAllNotificationTypes()) { - idsAfter.put(nt.getNotificationTypeKey(), nt.getNotificationTypeId()); + for (String key : EXPECTED_KEYS) { + int deletesAfter = NotificationTypeProviderServiceMockHandler.getDeleteCount(key); + assertEquals( + deletesBefore.get(key) + 1, + deletesAfter, + "NotificationType '" + key + "' should have been deleted once during re-provisioning"); } assertEquals( - idsBefore, - idsAfter, - "NotificationTypeIds should remain the same after re-provisioning (UPDATE, not INSERT)"); + EXPECTED_KEYS.size(), + NotificationTypeProviderServiceMockHandler.getNotificationTypeCount(), + "All types should still exist after re-provisioning (delete+create)"); - LOG.debug("Re-provisioning verified — all types retain their IDs"); + LOG.debug("Re-provisioning verified — all types were deleted and recreated"); } // ────────────────────────────────────────────────────────────── - // Test 3: Update count matches expected types + // Test 3: Re-provisioning deletes and recreates all types // ────────────────────────────────────────────────────────────── @Test void testReProvisioningUpdatesAllTypes() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should trigger UPDATE for each existing type"); + LOG.debug("Test: Re-provisioning should trigger DELETE+CREATE for each existing type"); LOG.debug("=========================================="); - Map countsBefore = new HashMap<>(); + Map deletesBefore = new HashMap<>(); for (String key : EXPECTED_KEYS) { - countsBefore.put(key, NotificationTypeProviderServiceMockHandler.getUpdateCount(key)); + deletesBefore.put(key, NotificationTypeProviderServiceMockHandler.getDeleteCount(key)); } createProvisioner().onApplicationPrepared(); for (String key : EXPECTED_KEYS) { - int before = countsBefore.get(key); - int after = NotificationTypeProviderServiceMockHandler.getUpdateCount(key); + int before = deletesBefore.get(key); + int after = NotificationTypeProviderServiceMockHandler.getDeleteCount(key); assertEquals( before + 1, after, "NotificationType '" + key - + "' should have been updated exactly once. Before: " + + "' should have been deleted exactly once. Before: " + before + ", After: " + after); - LOG.debug("Type '{}': update count {} → {}", key, before, after); + LOG.debug("Type '{}': delete count {} → {}", key, before, after); } - LOG.debug("All {} types were updated during re-provisioning", EXPECTED_KEYS.size()); + LOG.debug("All {} types were deleted and recreated during re-provisioning", EXPECTED_KEYS.size()); } } From 7b39260f8e9dd0e2764eaaca64319011506dcd19 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 15:55:19 +0200 Subject: [PATCH 07/27] docs: update CHANGELOG with ANS spec compliance changes and missing entries --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba5dbe..55b6cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,16 @@ 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`) +- Translation locales filtered per event to exclude framework-only locales (e.g. `@sap/cds/common` translations) that have no app-specific notification texts +- Automatic notification type provisioning to ANS at application startup (`NotificationTypeAutoProvisionerHandler`) using `Translations` payload to comply with ANS API spec +- Required field validation for `@notification.template.groupedTitle` annotation — throws `IllegalStateException` at startup if missing +- Re-provisioning of `NotificationType` and `NotificationTemplate` uses delete+create strategy to avoid ANS 400 rejection when existing type has `Templates` but new payload uses `Translations`, and because ANS does not provide a PATCH endpoint for templates +- Explicit `PRIVATE` visibility set on `NotificationTemplate` 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 From 036b2c2ff0e7dcb2e7ea1a397404dfc3a86b3810 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 16:04:41 +0200 Subject: [PATCH 08/27] chore: apply spotless formatting --- .../assemblers/NotificationTypeAssembler.java | 2 + ...otificationTypeAutoProvisionerHandler.java | 14 +++++-- .../cds/notifications/helpers/I18nHelper.java | 10 +++-- ...ionTemplateProviderServiceMockHandler.java | 32 +++++++++----- ...icationTypeProviderServiceMockHandler.java | 42 ++++++++++++------- .../NotificationTemplateProvisioningTest.java | 6 +-- .../NotificationTypeProvisioningTest.java | 6 ++- 7 files changed, 72 insertions(+), 40 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java index 1b3bb7b..01bbf54 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java @@ -66,12 +66,14 @@ private Optional extractNotificationTypeFromEvent(CdsEvent ev /** ANS Translation field constraints (per ANS API spec). */ private static final int MAX_LANGUAGE_LENGTH = 20; + private static final int MAX_TEXT_LENGTH = 256; /** * Extract Translations from CDS event annotations for all available i18n locales. * *

Mapping: + * *

    *
  • {@code @notification.template.title} → DisplayName (required) *
  • {@code @notification.template.groupedTitle} → GroupTitle (required) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java index b396c48..c481af6 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java @@ -53,7 +53,8 @@ private void logNotificationType(NotificationTypes notificationType) { logger.info("│ NotificationType (Local Mode - Not Sent to ANS)"); logger.info("│ Key: {}", notificationType.getNotificationTypeKey()); logger.info("│ Version: {}", notificationType.getNotificationTypeVersion()); - logger.info("│ Templates ({}):", + logger.info( + "│ Templates ({}):", notificationType.getTemplates() != null ? notificationType.getTemplates().size() : 0); if (notificationType.getTemplates() != null) { @@ -65,7 +66,9 @@ private void logNotificationType(NotificationTypes notificationType) { logger.info("│ Subtitle: {}", template.getSubtitle()); logger.info("│ Email Subject: {}", template.getEmailSubject()); if (template.getEmailHtml() != null) { - String preview = template.getEmailHtml().substring(0, Math.min(100, template.getEmailHtml().length())) + "..."; + String preview = + template.getEmailHtml().substring(0, Math.min(100, template.getEmailHtml().length())) + + "..."; logger.info("│ Email HTML: {}", preview); } } @@ -74,8 +77,11 @@ private void logNotificationType(NotificationTypes notificationType) { if (notificationType.getDeliveryChannels() != null) { logger.info("│ Delivery Channels ({}):", notificationType.getDeliveryChannels().size()); for (DeliveryChannels channel : notificationType.getDeliveryChannels()) { - logger.info("│ - Type: {}, Enabled: {}, DefaultPreference: {}", - channel.getType(), channel.getEnabled(), channel.getDefaultPreference()); + logger.info( + "│ - Type: {}, Enabled: {}, DefaultPreference: {}", + channel.getType(), + channel.getEnabled(), + channel.getDefaultPreference()); } } logger.info("└──────────────────────────────────────────────────────────────┘"); diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java index 08722ad..cc81cae 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java @@ -75,14 +75,18 @@ public Set getAvailableLocalesForEvent(CdsEvent event) { continue; } Map localeTexts = getI18nTexts(locale); - String localeTitle = resolveAnnotationValue(event, "notification.template.title", localeTexts); + String localeTitle = + resolveAnnotationValue(event, "notification.template.title", localeTexts); if (!Objects.equals(enTitle, localeTitle)) { filtered.add(locale); } } - logger.debug("Filtered locales for '{}': {} (skipped {} without notification translations)", - event.getName(), filtered, allLocales.size() - filtered.size()); + logger.debug( + "Filtered locales for '{}': {} (skipped {} without notification translations)", + event.getName(), + filtered, + allLocales.size() - filtered.size()); return filtered; } diff --git a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java index 8f65a30..fc44ebc 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java +++ b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java @@ -84,17 +84,27 @@ public void interceptCreate(CdsCreateEventContext context) { public void interceptDelete(CdsDeleteEventContext context) { logger.debug("MockHandler intercepting NotificationTemplates DELETE"); - context.getCqn().where().ifPresent(where -> { - String whereStr = where.toString(); - templateStore.entrySet().removeIf(entry -> { - boolean matches = whereStr.contains(entry.getKey()); - if (matches) { - deleteCountByKey.computeIfAbsent(entry.getKey(), k -> new AtomicInteger(0)).incrementAndGet(); - logger.debug("MockHandler deleted notification template: Key={}", entry.getKey()); - } - return matches; - }); - }); + context + .getCqn() + .where() + .ifPresent( + where -> { + String whereStr = where.toString(); + templateStore + .entrySet() + .removeIf( + entry -> { + boolean matches = whereStr.contains(entry.getKey()); + if (matches) { + deleteCountByKey + .computeIfAbsent(entry.getKey(), k -> new AtomicInteger(0)) + .incrementAndGet(); + logger.debug( + "MockHandler deleted notification template: Key={}", entry.getKey()); + } + return matches; + }); + }); context.setResult(Collections.emptyList()); context.setCompleted(); diff --git a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java index c0adac8..811b6e1 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java +++ b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java @@ -101,22 +101,32 @@ public void interceptCreate(CdsCreateEventContext context) { public void interceptDelete(CdsDeleteEventContext context) { logger.debug("MockHandler intercepting NotificationTypes DELETE"); - context.getCqn().where().ifPresent(where -> { - // Extract the ID from the WHERE clause and delete from store - notificationTypeStore.entrySet().removeIf(entry -> { - NotificationTypes nt = entry.getValue(); - String id = nt.getNotificationTypeId(); - boolean matches = where.toString().contains(id != null ? id : ""); - if (matches) { - String key = nt.getNotificationTypeKey(); - String keyVersion = key + ":" + nt.getNotificationTypeVersion(); - notificationTypeByKeyVersion.remove(keyVersion); - deleteCountByKey.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet(); - logger.debug("MockHandler deleted notification type: Key={}, ID={}", key, id); - } - return matches; - }); - }); + context + .getCqn() + .where() + .ifPresent( + where -> { + // Extract the ID from the WHERE clause and delete from store + notificationTypeStore + .entrySet() + .removeIf( + entry -> { + NotificationTypes nt = entry.getValue(); + String id = nt.getNotificationTypeId(); + boolean matches = where.toString().contains(id != null ? id : ""); + if (matches) { + String key = nt.getNotificationTypeKey(); + String keyVersion = key + ":" + nt.getNotificationTypeVersion(); + notificationTypeByKeyVersion.remove(keyVersion); + deleteCountByKey + .computeIfAbsent(key, k -> new AtomicInteger(0)) + .incrementAndGet(); + logger.debug( + "MockHandler deleted notification type: Key={}, ID={}", key, id); + } + return matches; + }); + }); context.setResult(Collections.emptyList()); context.setCompleted(); diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java index 12b55c1..5945988 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java @@ -121,8 +121,7 @@ void testCertificateExpirationTemplateStructure() { @Test void testVisibilityDefaultsToPrivate() { LOG.debug("=========================================="); - LOG.debug( - "Test: Templates without @notification.customizable should have PRIVATE visibility"); + LOG.debug("Test: Templates without @notification.customizable should have PRIVATE visibility"); LOG.debug("=========================================="); // SystemMaintenance has no @notification.customizable → visibility should be PRIVATE @@ -134,8 +133,7 @@ void testVisibilityDefaultsToPrivate() { template.getVisibility(), "Template without @notification.customizable should have PRIVATE visibility"); - LOG.debug( - "SystemMaintenance visibility: {}", template.getVisibility()); + LOG.debug("SystemMaintenance visibility: {}", template.getVisibility()); } // ────────────────────────────────────────────────────────────── diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java index e2a98d8..c53bdf1 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java @@ -100,7 +100,8 @@ void testReProvisioningUpdatesEachTypeCorrectly() { deletesBefore.put(key, NotificationTypeProviderServiceMockHandler.getDeleteCount(key)); } assertEquals( - EXPECTED_KEYS.size(), NotificationTypeProviderServiceMockHandler.getNotificationTypeCount(), + EXPECTED_KEYS.size(), + NotificationTypeProviderServiceMockHandler.getNotificationTypeCount(), "All types should exist before re-provisioning"); createProvisioner().onApplicationPrepared(); @@ -155,6 +156,7 @@ void testReProvisioningUpdatesAllTypes() { LOG.debug("Type '{}': delete count {} → {}", key, before, after); } - LOG.debug("All {} types were deleted and recreated during re-provisioning", EXPECTED_KEYS.size()); + LOG.debug( + "All {} types were deleted and recreated during re-provisioning", EXPECTED_KEYS.size()); } } From f96ffca15273274bd1c343289272a332d7d8b025 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 16:16:00 +0200 Subject: [PATCH 09/27] test: add unit tests for NotificationTypeAssembler --- .../NotificationTypeAssemblerTest.java | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java new file mode 100644 index 0000000..fb92ac5 --- /dev/null +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java @@ -0,0 +1,123 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. + */ +package com.sap.cds.notifications.assemblers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import cds.gen.notificationtypeproviderservice.NotificationTypes; +import cds.gen.notificationtypeproviderservice.Translations; +import com.sap.cds.adapter.edmx.EdmxI18nProvider; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsEvent; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NotificationTypeAssemblerTest { + + private CdsRuntime runtime; + private EdmxI18nProvider i18nProvider; + + @BeforeEach + void setUp() { + runtime = mock(CdsRuntime.class); + i18nProvider = mock(EdmxI18nProvider.class); + when(runtime.getProvider(EdmxI18nProvider.class)).thenReturn(i18nProvider); + when(i18nProvider.getLocales()).thenReturn(Set.of(Locale.ENGLISH)); + when(i18nProvider.getTexts(Locale.ENGLISH)).thenReturn(Collections.emptyMap()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private CdsEvent mockEvent(String name, String title, String groupedTitle) { + CdsEvent event = mock(CdsEvent.class); + when(event.getName()).thenReturn(name); + + CdsAnnotation titleAnno = mock(CdsAnnotation.class); + when(titleAnno.getValue()).thenReturn(title != null ? title : ""); + when(event.findAnnotation("notification.template.title")) + .thenReturn(title != null ? Optional.of(titleAnno) : Optional.empty()); + + if (groupedTitle != null) { + CdsAnnotation groupAnno = mock(CdsAnnotation.class); + when(groupAnno.getValue()).thenReturn(groupedTitle); + when(event.findAnnotation("notification.template.groupedTitle")) + .thenReturn(Optional.of(groupAnno)); + } else { + when(event.findAnnotation("notification.template.groupedTitle")).thenReturn(Optional.empty()); + } + + when(event.findAnnotation("description")).thenReturn(Optional.empty()); + when(event.findAnnotation("notification.deliveryChannels")).thenReturn(Optional.empty()); + return event; + } + + private List build(CdsEvent event) { + CdsModel model = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(model); + when(model.events()).thenReturn(Stream.of(event)); + return new NotificationTypeAssembler(runtime).buildAllNotificationTypes(); + } + + @Test + void happyPath_translationBuiltCorrectly() { + CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); + List result = build(event); + + assertEquals(1, result.size()); + Translations t = result.get(0).getTranslations().get(0); + assertEquals("New book order", t.getDisplayName()); + assertEquals("{{_group_count}} new orders", t.getGroupTitle()); + assertEquals("MUSTACHE", t.getSyntax()); + assertEquals("en", t.getLanguage()); + } + + @Test + void missingGroupedTitle_throwsIllegalStateException() { + CdsEvent event = mockEvent("BookOrdered", "New book order", null); + assertThrows(IllegalStateException.class, () -> build(event)); + } + + @Test + void displayNameExceeding256Chars_truncated() { + CdsEvent event = mockEvent("BookOrdered", "A".repeat(300), "{{_group_count}} new orders"); + Translations t = build(event).get(0).getTranslations().get(0); + assertEquals(256, t.getDisplayName().length()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void description_setWhenPresent() { + CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); + CdsAnnotation descAnno = mock(CdsAnnotation.class); + when(descAnno.getValue()).thenReturn("Handles book orders"); + when(event.findAnnotation("description")).thenReturn(Optional.of(descAnno)); + + Translations t = build(event).get(0).getTranslations().get(0); + assertEquals("Handles book orders", t.getDescription()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void deliveryChannels_parsedCorrectly() { + CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); + CdsAnnotation channelsAnno = mock(CdsAnnotation.class); + when(channelsAnno.getValue()).thenReturn(List.of( + Map.of("channel", "MAIL", "enabled", true, "defaultPreference", true))); + when(event.findAnnotation("notification.deliveryChannels")).thenReturn(Optional.of(channelsAnno)); + + List result = build(event); + assertNotNull(result.get(0).getDeliveryChannels()); + assertEquals(1, result.get(0).getDeliveryChannels().size()); + assertEquals("MAIL", result.get(0).getDeliveryChannels().get(0).getType()); + } +} From 3218fc5b87f221af85ee98851598ef03f2bf2e4f Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Tue, 23 Jun 2026 16:18:34 +0200 Subject: [PATCH 10/27] chore: apply spotless formatting to NotificationTypeAssemblerTest --- .../assemblers/NotificationTypeAssemblerTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java index fb92ac5..bd8c2df 100644 --- a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java @@ -111,9 +111,10 @@ void description_setWhenPresent() { void deliveryChannels_parsedCorrectly() { CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); CdsAnnotation channelsAnno = mock(CdsAnnotation.class); - when(channelsAnno.getValue()).thenReturn(List.of( - Map.of("channel", "MAIL", "enabled", true, "defaultPreference", true))); - when(event.findAnnotation("notification.deliveryChannels")).thenReturn(Optional.of(channelsAnno)); + when(channelsAnno.getValue()) + .thenReturn(List.of(Map.of("channel", "MAIL", "enabled", true, "defaultPreference", true))); + when(event.findAnnotation("notification.deliveryChannels")) + .thenReturn(Optional.of(channelsAnno)); List result = build(event); assertNotNull(result.get(0).getDeliveryChannels()); From 29d32086d041a6d602e7fedee4d95afd7aef1e38 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 10:20:02 +0200 Subject: [PATCH 11/27] fix: use @notification.template.publicTitle for DisplayName in NotificationType Translations instead of @notification.template.title, and throw on missing required annotation --- .../assemblers/NotificationTypeAssembler.java | 31 +++++++------------ .../NotificationTypeAssemblerTest.java | 12 ++++--- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java index 01bbf54..70778e4 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java @@ -64,18 +64,13 @@ private Optional extractNotificationTypeFromEvent(CdsEvent ev return Optional.of(nt); } - /** ANS Translation field constraints (per ANS API spec). */ - private static final int MAX_LANGUAGE_LENGTH = 20; - - private static final int MAX_TEXT_LENGTH = 256; - /** * Extract Translations from CDS event annotations for all available i18n locales. * *

    Mapping: * *

      - *
    • {@code @notification.template.title} → DisplayName (required) + *
    • {@code @notification.template.publicTitle} → DisplayName (non-sensitive, shown in user preferences)
    • *
    • {@code @notification.template.groupedTitle} → GroupTitle (required) *
    • {@code @description} → Description (optional) *
    @@ -88,15 +83,18 @@ private List extractTranslations(CdsEvent event) { Map i18nTexts = i18nHelper.getI18nTexts(locale); Translations translation = Struct.create(Translations.class); - translation.setLanguage(truncate(locale.toLanguageTag(), MAX_LANGUAGE_LENGTH)); + translation.setLanguage(locale.toLanguageTag()); - // DisplayName — from @notification.template.title + // DisplayName — from @notification.template.publicTitle (non-sensitive, shown in user preferences) String displayName = - i18nHelper.resolveAnnotationValue(event, "notification.template.title", i18nTexts); + i18nHelper.resolveAnnotationValue(event, "notification.template.publicTitle", i18nTexts); if (displayName == null || displayName.isBlank()) { - displayName = event.getName(); + throw new IllegalStateException( + String.format( + "Missing required annotation: @notification.template.publicTitle for event '%s'.", + event.getName())); } - translation.setDisplayName(truncate(displayName, MAX_TEXT_LENGTH)); + translation.setDisplayName(displayName); // GroupTitle — from @notification.template.groupedTitle (required) String groupTitle = @@ -107,14 +105,14 @@ private List extractTranslations(CdsEvent event) { "Missing required annotation: @notification.template.groupedTitle for event '%s'.", event.getName())); } - translation.setGroupTitle(truncate(groupTitle, MAX_TEXT_LENGTH)); + translation.setGroupTitle(groupTitle); translation.setSyntax("MUSTACHE"); // Description — from @description (optional) String description = i18nHelper.resolveAnnotationValue(event, "description", i18nTexts); if (description != null && !description.isBlank()) { - translation.setDescription(truncate(description, MAX_TEXT_LENGTH)); + translation.setDescription(description); } translations.add(translation); @@ -128,13 +126,6 @@ private List extractTranslations(CdsEvent event) { return translations; } - private static String truncate(String value, int maxLength) { - if (value == null || value.length() <= maxLength) { - return value; - } - return value.substring(0, maxLength); - } - @SuppressWarnings("unchecked") private List extractDeliveryChannels(CdsEvent event) { List deliveryChannels = new ArrayList<>(); diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java index bd8c2df..f530ac8 100644 --- a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java @@ -44,7 +44,10 @@ private CdsEvent mockEvent(String name, String title, String groupedTitle) { CdsAnnotation titleAnno = mock(CdsAnnotation.class); when(titleAnno.getValue()).thenReturn(title != null ? title : ""); - when(event.findAnnotation("notification.template.title")) + // @notification.template.title is used to filter events in buildAllNotificationTypes + when(event.findAnnotation("notification.template.title")).thenReturn(Optional.of(mock(CdsAnnotation.class))); + // @notification.template.publicTitle is used as DisplayName in Translations + when(event.findAnnotation("notification.template.publicTitle")) .thenReturn(title != null ? Optional.of(titleAnno) : Optional.empty()); if (groupedTitle != null) { @@ -88,10 +91,9 @@ void missingGroupedTitle_throwsIllegalStateException() { } @Test - void displayNameExceeding256Chars_truncated() { - CdsEvent event = mockEvent("BookOrdered", "A".repeat(300), "{{_group_count}} new orders"); - Translations t = build(event).get(0).getTranslations().get(0); - assertEquals(256, t.getDisplayName().length()); + void missingPublicTitle_throwsIllegalStateException() { + CdsEvent event = mockEvent("BookOrdered", null, "{{_group_count}} new orders"); + assertThrows(IllegalStateException.class, () -> build(event)); } @Test From 50f751f1dc857cd0261048914bb60e16e4c524da Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 10:46:43 +0200 Subject: [PATCH 12/27] chore: apply spotless formatting --- .../notifications/assemblers/NotificationTypeAssembler.java | 6 ++++-- .../assemblers/NotificationTypeAssemblerTest.java | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java index 70778e4..1fea014 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java @@ -70,7 +70,8 @@ private Optional extractNotificationTypeFromEvent(CdsEvent ev *

    Mapping: * *

      - *
    • {@code @notification.template.publicTitle} → DisplayName (non-sensitive, shown in user preferences)
    • + *
    • {@code @notification.template.publicTitle} → DisplayName (non-sensitive, shown in user + * preferences) *
    • {@code @notification.template.groupedTitle} → GroupTitle (required) *
    • {@code @description} → Description (optional) *
    @@ -85,7 +86,8 @@ private List extractTranslations(CdsEvent event) { Translations translation = Struct.create(Translations.class); translation.setLanguage(locale.toLanguageTag()); - // DisplayName — from @notification.template.publicTitle (non-sensitive, shown in user preferences) + // 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()) { diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java index f530ac8..c10a1e8 100644 --- a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java @@ -45,7 +45,8 @@ private CdsEvent mockEvent(String name, String title, String groupedTitle) { CdsAnnotation titleAnno = mock(CdsAnnotation.class); when(titleAnno.getValue()).thenReturn(title != null ? title : ""); // @notification.template.title is used to filter events in buildAllNotificationTypes - when(event.findAnnotation("notification.template.title")).thenReturn(Optional.of(mock(CdsAnnotation.class))); + when(event.findAnnotation("notification.template.title")) + .thenReturn(Optional.of(mock(CdsAnnotation.class))); // @notification.template.publicTitle is used as DisplayName in Translations when(event.findAnnotation("notification.template.publicTitle")) .thenReturn(title != null ? Optional.of(titleAnno) : Optional.empty()); From 8590d10560f7d39be91020e0762e99d52b0a035f Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 11:33:05 +0200 Subject: [PATCH 13/27] refactor: replace delete+create with PUT update strategy for NotificationTemplate re-provisioning --- ...icationTemplateAutoProvisionerHandler.java | 19 +++++++++---------- .../NotificationTemplateProvisioningTest.java | 18 +++++++++--------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java index baa598d..0eca42a 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java @@ -7,9 +7,9 @@ import cds.gen.notificationtemplateproviderservice.NotificationTemplates; import cds.gen.notificationtemplateproviderservice.NotificationTemplates_; import com.sap.cds.notifications.assemblers.NotificationTemplateAssembler; -import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; @@ -133,20 +133,19 @@ private void createTemplate(NotificationTemplates template) { } private void updateTemplate(NotificationTemplates template) { - logger.debug("Updating standalone template '{}' (delete + re-create)", template.getKey()); + logger.debug("Updating standalone template '{}' via PUT", template.getKey()); try { notificationTemplateProviderService.run( - Delete.from(NotificationTemplates_.class).where(nt -> nt.Key().eq(template.getKey()))); + Update.entity(NotificationTemplates_.CDS_NAME) + .data(template) + .where(t -> t.get("Key").eq(template.getKey()))); - logger.debug("Deleted existing standalone template '{}'", template.getKey()); + logger.debug( + "Standalone NotificationTemplate '{}' updated in ANS successfully", template.getKey()); } catch (Exception e) { - logger.warn( - "Could not delete existing standalone template '{}': {}", - template.getKey(), - e.getMessage()); + logger.error("Failed to update standalone template '{}' in ANS", template.getKey(), e); + throw e; } - - createTemplate(template); } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java index 5945988..b86775e 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java @@ -553,29 +553,29 @@ private void assertNoUnresolvedI18n(String value, String fieldName, String lang) @Test void testReProvisioningUpdatesExistingTemplates() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should DELETE and recreate existing templates"); + LOG.debug("Test: Re-provisioning should UPDATE existing templates via PUT"); LOG.debug("=========================================="); int countBefore = NotificationTemplateProviderServiceMockHandler.getTemplateCount(); assertTrue(countBefore > 0, "Templates should already be provisioned at startup"); - int deletesBefore = - NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); + int updatesBefore = + NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); createProvisioner().onApplicationPrepared(); - int deletesAfter = - NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); + int updatesAfter = + NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); assertEquals( - deletesBefore + 1, - deletesAfter, - "CertificateExpiration template should have been deleted once during re-provisioning"); + updatesBefore + 1, + updatesAfter, + "CertificateExpiration template should have been updated once during re-provisioning"); assertEquals( countBefore, NotificationTemplateProviderServiceMockHandler.getTemplateCount(), "Template count should remain the same after re-provisioning"); - LOG.debug("Re-provisioning triggered DELETE+CREATE for existing templates"); + LOG.debug("Re-provisioning triggered PUT update for existing templates"); } } From 1a0b71c29b0a2520e22df10a9dc548ead696077e Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 11:40:38 +0200 Subject: [PATCH 14/27] Revert "refactor: replace delete+create with PUT update strategy for NotificationTemplate re-provisioning" This reverts commit 8590d10560f7d39be91020e0762e99d52b0a035f. --- ...icationTemplateAutoProvisionerHandler.java | 19 ++++++++++--------- .../NotificationTemplateProvisioningTest.java | 18 +++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java index 0eca42a..baa598d 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java @@ -7,9 +7,9 @@ import cds.gen.notificationtemplateproviderservice.NotificationTemplates; import cds.gen.notificationtemplateproviderservice.NotificationTemplates_; import com.sap.cds.notifications.assemblers.NotificationTemplateAssembler; +import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.ql.Update; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; @@ -133,19 +133,20 @@ private void createTemplate(NotificationTemplates template) { } private void updateTemplate(NotificationTemplates template) { - logger.debug("Updating standalone template '{}' via PUT", template.getKey()); + logger.debug("Updating standalone template '{}' (delete + re-create)", template.getKey()); try { notificationTemplateProviderService.run( - Update.entity(NotificationTemplates_.CDS_NAME) - .data(template) - .where(t -> t.get("Key").eq(template.getKey()))); + Delete.from(NotificationTemplates_.class).where(nt -> nt.Key().eq(template.getKey()))); - logger.debug( - "Standalone NotificationTemplate '{}' updated in ANS successfully", template.getKey()); + logger.debug("Deleted existing standalone template '{}'", template.getKey()); } catch (Exception e) { - logger.error("Failed to update standalone template '{}' in ANS", template.getKey(), e); - throw e; + logger.warn( + "Could not delete existing standalone template '{}': {}", + template.getKey(), + e.getMessage()); } + + createTemplate(template); } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java index b86775e..5945988 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java @@ -553,29 +553,29 @@ private void assertNoUnresolvedI18n(String value, String fieldName, String lang) @Test void testReProvisioningUpdatesExistingTemplates() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should UPDATE existing templates via PUT"); + LOG.debug("Test: Re-provisioning should DELETE and recreate existing templates"); LOG.debug("=========================================="); int countBefore = NotificationTemplateProviderServiceMockHandler.getTemplateCount(); assertTrue(countBefore > 0, "Templates should already be provisioned at startup"); - int updatesBefore = - NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); + int deletesBefore = + NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); createProvisioner().onApplicationPrepared(); - int updatesAfter = - NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); + int deletesAfter = + NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); assertEquals( - updatesBefore + 1, - updatesAfter, - "CertificateExpiration template should have been updated once during re-provisioning"); + deletesBefore + 1, + deletesAfter, + "CertificateExpiration template should have been deleted once during re-provisioning"); assertEquals( countBefore, NotificationTemplateProviderServiceMockHandler.getTemplateCount(), "Template count should remain the same after re-provisioning"); - LOG.debug("Re-provisioning triggered PUT update for existing templates"); + LOG.debug("Re-provisioning triggered DELETE+CREATE for existing templates"); } } From a98815b17f5a60db076ed98532c932589f53a09b Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 11:54:38 +0200 Subject: [PATCH 15/27] docs: update CHANGELOG to remove implementation details --- CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b6cff..fc2d28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - Support for email templates via `template.email.subject` and `template.email.html` fields - 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 -- Translation locales filtered per event to exclude framework-only locales (e.g. `@sap/cds/common` translations) that have no app-specific notification texts -- Automatic notification type provisioning to ANS at application startup (`NotificationTypeAutoProvisionerHandler`) using `Translations` payload to comply with ANS API spec -- Required field validation for `@notification.template.groupedTitle` annotation — throws `IllegalStateException` at startup if missing -- Re-provisioning of `NotificationType` and `NotificationTemplate` uses delete+create strategy to avoid ANS 400 rejection when existing type has `Templates` but new payload uses `Translations`, and because ANS does not provide a PATCH endpoint for templates -- Explicit `PRIVATE` visibility set on `NotificationTemplate` when `@notification.customizable` annotation is absent +- 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; defaults to local mode when property is not set or is `null` From b991a779c3893778911d8b63f92af9d2b1d1625e Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 14:22:22 +0200 Subject: [PATCH 16/27] refactor: update LocalNotificationTypeAutoProvisionerHandler to log Translations instead of embedded Templates --- ...otificationTypeAutoProvisionerHandler.java | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java index c481af6..41c58a4 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java @@ -5,7 +5,7 @@ import cds.gen.notificationtypeproviderservice.DeliveryChannels; import cds.gen.notificationtypeproviderservice.NotificationTypes; -import cds.gen.notificationtypeproviderservice.Templates; +import cds.gen.notificationtypeproviderservice.Translations; import com.sap.cds.notifications.assemblers.NotificationTypeAssembler; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; @@ -13,6 +13,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,64 +31,70 @@ public LocalNotificationTypeAutoProvisionerHandler(CdsRuntime runtime) { @On(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) public void onApplicationPrepared() { - logger.info( - "Auto-provisioning NotificationTypes from CDS annotations (LOCAL MODE - Logging Only)..."); try { provisionNotificationTypes(); - logger.info("Auto-provisioning completed (LOCAL MODE)"); } catch (Exception e) { - logger.error("Auto-provisioning failed", e); + logger.error("NotificationType auto-provisioning failed", e); } } private void provisionNotificationTypes() { List notificationTypes = notificationTypeBuilder.buildAllNotificationTypes(); + if (notificationTypes.isEmpty()) { + logger.info("No NotificationTypes found in CDS model"); + return; + } + + logger.info("╔══════════════════════════════════════════════════════════════╗"); + logger.info("║ NOTIFICATION TYPES (Local Mode — Not Sent to ANS) ║"); + logger.info("╚══════════════════════════════════════════════════════════════╝"); + for (NotificationTypes notificationType : notificationTypes) { logNotificationType(notificationType); } } private void logNotificationType(NotificationTypes notificationType) { + int translationCount = + notificationType.getTranslations() != null ? notificationType.getTranslations().size() : 0; + int channelCount = + notificationType.getDeliveryChannels() != null + ? notificationType.getDeliveryChannels().size() + : 0; + logger.info("┌──────────────────────────────────────────────────────────────┐"); - logger.info("│ NotificationType (Local Mode - Not Sent to ANS)"); - logger.info("│ Key: {}", notificationType.getNotificationTypeKey()); - logger.info("│ Version: {}", notificationType.getNotificationTypeVersion()); logger.info( - "│ Templates ({}):", - notificationType.getTemplates() != null ? notificationType.getTemplates().size() : 0); + "│ Type: '{}' | translations={} | channels={}", + notificationType.getNotificationTypeKey(), + translationCount, + channelCount); + logger.info("├──────────────────────────────────────────────────────────────┤"); - if (notificationType.getTemplates() != null) { - for (Templates template : notificationType.getTemplates()) { - logger.info("│ - Language: {}", template.getLanguage()); - logger.info("│ Public Title: {}", template.getTemplatePublic()); - logger.info("│ Sensitive Title: {}", template.getTemplateSensitive()); - logger.info("│ Grouped Title: {}", template.getTemplateGrouped()); - logger.info("│ Subtitle: {}", template.getSubtitle()); - logger.info("│ Email Subject: {}", template.getEmailSubject()); - if (template.getEmailHtml() != null) { - String preview = - template.getEmailHtml().substring(0, Math.min(100, template.getEmailHtml().length())) - + "..."; - logger.info("│ Email HTML: {}", preview); - } - } + if (notificationType.getTranslations() != null + && !notificationType.getTranslations().isEmpty()) { + List translations = notificationType.getTranslations(); + String languages = + translations.stream().map(Translations::getLanguage).collect(Collectors.joining(", ")); + Translations display = + translations.stream() + .filter(t -> "en".equals(t.getLanguage())) + .findFirst() + .orElse(translations.get(0)); + logger.info("│ displayName: {} [{}]", display.getDisplayName(), languages); + logger.info("│ groupTitle: {} [{}]", display.getGroupTitle(), languages); } if (notificationType.getDeliveryChannels() != null) { - logger.info("│ Delivery Channels ({}):", notificationType.getDeliveryChannels().size()); + logger.info("│ Delivery Channels:"); for (DeliveryChannels channel : notificationType.getDeliveryChannels()) { logger.info( - "│ - Type: {}, Enabled: {}, DefaultPreference: {}", + "│ - {} (enabled={}, defaultPreference={})", channel.getType(), channel.getEnabled(), channel.getDefaultPreference()); } } logger.info("└──────────────────────────────────────────────────────────────┘"); - - logger.info( - "NotificationType '{}' logged (LOCAL MODE - not sent to ANS)", - notificationType.getNotificationTypeKey()); } } From f6e714d909391d9a22df4a8801664fbecc7f6c33 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 15:02:02 +0200 Subject: [PATCH 17/27] fix: remove DisplayName from NotificationTemplate translation as it is not part of the ANS API spec --- .../assemblers/NotificationTemplateAssembler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java index b032ef0..e66c0fa 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java @@ -113,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 = From 669a921f901ca77e8b01f68f83ca08f6023d2453 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 15:02:16 +0200 Subject: [PATCH 18/27] refactor: improve local mode logging for NotificationTemplate provisioning --- ...icationTemplateAutoProvisionerHandler.java | 92 ++++++++++--------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java index fefeec7..027b9bb 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java @@ -11,7 +11,10 @@ import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.runtime.CdsRuntime; +import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,12 +36,8 @@ public LocalNotificationTemplateAutoProvisionerHandler(CdsRuntime runtime) { @On(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) public void onApplicationPrepared() { - logger.info( - "Auto-provisioning standalone NotificationTemplates from CDS annotations" - + " (LOCAL MODE - Logging Only)..."); try { provisionNotificationTemplates(); - logger.info("Standalone NotificationTemplate auto-provisioning completed (LOCAL MODE)"); } catch (Exception e) { logger.error("Standalone NotificationTemplate auto-provisioning failed", e); } @@ -49,66 +48,71 @@ private void provisionNotificationTemplates() { notificationTemplateBuilder.buildAllNotificationTemplates(); if (templates.isEmpty()) { - logger.info("No standalone NotificationTemplates found in CDS model (LOCAL MODE)"); + logger.info("No standalone NotificationTemplates found in CDS model"); return; } + logger.info("╔══════════════════════════════════════════════════════════════╗"); + logger.info("║ NOTIFICATION TEMPLATES (Local Mode — Not Sent to ANS) ║"); + logger.info("╚══════════════════════════════════════════════════════════════╝"); + for (NotificationTemplates template : templates) { logTemplate(template); } } private void logTemplate(NotificationTemplates template) { - logger.info("==============================================================="); - logger.info("Standalone NotificationTemplate (Local Mode - Not Sent to ANS)"); + int translationCount = + template.getTranslations() != null ? template.getTranslations().size() : 0; + + logger.info("┌──────────────────────────────────────────────────────────────┐"); logger.info( - """ - Key: {} - Visibility: {} - Translations: {}""", + "│ Template: '{}' | visibility={} | translations={}", template.getKey(), template.getVisibility(), - template.getTranslations() != null ? template.getTranslations().size() : 0); + translationCount); + + if (template.getPropertiesSchema() != null) { + logger.info("│ Properties: {}", extractPropertyNames(template.getPropertiesSchema())); + } + + logger.info("├──────────────────────────────────────────────────────────────┤"); if (template.getTranslations() != null) { for (Translations translation : template.getTranslations()) { - logger.info( - """ - - Language: {} - Syntax: {} - Title: {} - Preview: {} - Body: {} - Description: {}""", - translation.getLanguage(), - translation.getSyntax(), - translation.getTitle(), - translation.getPreview(), - translation.getBody(), - translation.getDescription()); + logger.info("│ [{}]", translation.getLanguage()); + logger.info("│ title: {}", translation.getTitle()); + logger.info("│ preview: {}", translation.getPreview()); + logger.info("│ body: {}", translation.getBody()); + logger.info("│ description: {}", translation.getDescription()); if (translation.getEmail() != null) { - logger.info( - """ - Email Subject: {} - Email BodyHtml: {} - Email BodyText: {}""", - translation.getEmail().getSubject(), - translation.getEmail().getBodyHtml() != null - ? translation - .getEmail() - .getBodyHtml() - .substring( - 0, Math.min(100, translation.getEmail().getBodyHtml().length())) - + "..." - : "null", - translation.getEmail().getBodyText()); + logger.info("│ email subject: {}", translation.getEmail().getSubject()); + logger.info("│ email bodyText: {}", translation.getEmail().getBodyText() != null + ? translation.getEmail().getBodyText() + : "(not set)"); + if (translation.getEmail().getBodyHtml() != null) { + logger.info("│ email bodyHtml: {}", + translation.getEmail().getBodyHtml() + .replaceAll("<[^>]+>", " ") + .replaceAll("\\s+", " ") + .trim()); + } else { + logger.info("│ email bodyHtml: (not set)"); + } } + logger.info("│"); } } + logger.info("└──────────────────────────────────────────────────────────────┘"); + } - logger.info("==============================================================="); - logger.info( - "Standalone template '{}' logged (LOCAL MODE - not sent to ANS)", template.getKey()); + private String extractPropertyNames(String propertiesSchema) { + List names = new ArrayList<>(); + Matcher matcher = Pattern.compile("\"([^\"]+)\":\\{\"type\"").matcher(propertiesSchema); + while (matcher.find()) { + names.add(matcher.group(1)); + } + return names.isEmpty() ? "(none)" : String.join(", ", names); } } From e32fc1e8d9cfa20c3eb78d7eda1743c47576e097 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 15:03:35 +0200 Subject: [PATCH 19/27] chore: apply spotless formatting --- ...otificationTemplateAutoProvisionerHandler.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java index 027b9bb..7364d9b 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java @@ -88,12 +88,17 @@ private void logTemplate(NotificationTemplates template) { if (translation.getEmail() != null) { logger.info("│ email subject: {}", translation.getEmail().getSubject()); - logger.info("│ email bodyText: {}", translation.getEmail().getBodyText() != null - ? translation.getEmail().getBodyText() - : "(not set)"); + logger.info( + "│ email bodyText: {}", + translation.getEmail().getBodyText() != null + ? translation.getEmail().getBodyText() + : "(not set)"); if (translation.getEmail().getBodyHtml() != null) { - logger.info("│ email bodyHtml: {}", - translation.getEmail().getBodyHtml() + logger.info( + "│ email bodyHtml: {}", + translation + .getEmail() + .getBodyHtml() .replaceAll("<[^>]+>", " ") .replaceAll("\\s+", " ") .trim()); From 5b76e023fcbe097bfde4035a401d8fe67eb8c9d0 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Wed, 24 Jun 2026 15:58:34 +0200 Subject: [PATCH 20/27] test: remove DisplayName assertion from NotificationTemplateProvisioningTest as DisplayName is no longer set --- .../integration/NotificationTemplateProvisioningTest.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java index 5945988..900990d 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java @@ -339,12 +339,7 @@ void testTranslationMetadata() { // Event = event name assertEquals("CertificateExpiration", en.getEvent(), "Event should be the event name"); - // DisplayName = event name - assertEquals( - "CertificateExpiration", en.getDisplayName(), "DisplayName should be the event name"); - - LOG.debug( - "Source={}, Event={}, DisplayName={}", en.getSource(), en.getEvent(), en.getDisplayName()); + LOG.debug("Source={}, Event={}", en.getSource(), en.getEvent()); } // ────────────────────────────────────────────────────────────── From 48f09f3bba704956f3705da1e3fa1c7614bb5628 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Thu, 25 Jun 2026 09:55:07 +0200 Subject: [PATCH 21/27] refactor: improve local mode logging for LocalHandler with box-style format, batch index, and email body rendering --- .../notifications/handlers/LocalHandler.java | 119 +++++++++++++----- 1 file changed, 88 insertions(+), 31 deletions(-) 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 c673335..2f56b10 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 @@ -5,8 +5,9 @@ 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; @@ -14,6 +15,9 @@ 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; @@ -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 = "*") @@ -35,43 +41,94 @@ 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 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 entry : props.entrySet()) { + htmlContent = htmlContent.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + int bodyStart = htmlContent.toLowerCase().indexOf(""); + 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 props) { + Map i18nTexts = i18nHelper.getI18nTexts(Locale.ENGLISH); + String template = i18nHelper.resolveAnnotationValue(event, annotationPath, i18nTexts); + if (template == null) { + return null; + } + for (Map.Entry entry : props.entrySet()) { + template = template.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + return template; + } } From 9688fbd6ad45f6c4ddf4544f9b56fecd503a27fb Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Thu, 25 Jun 2026 09:56:35 +0200 Subject: [PATCH 22/27] chore: apply spotless formatting --- .../notifications/handlers/LocalHandler.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 2f56b10..6e42d54 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 @@ -64,8 +64,7 @@ public void postNotifications(EventContext context) { .map(r -> r.getRecipientId() != null ? r.getRecipientId() : r.getGlobalUserId()) .collect(Collectors.joining(", ")); - String priority = - notification.getPriority() != null ? notification.getPriority() : "NEUTRAL"; + String priority = notification.getPriority() != null ? notification.getPriority() : "NEUTRAL"; String index = results.size() > 1 ? " (" + (i + 1) + "/" + results.size() + ")" : ""; @@ -89,18 +88,21 @@ public void postNotifications(EventContext context) { logger.info("│ Body (text): {}", emailBodyText); } if (emailBodyHtml != null) { - String htmlContent = emailBodyHtml.endsWith(".html") - ? i18nHelper.loadHtmlFromClasspath(emailBodyHtml, i18nHelper.getI18nTexts(Locale.ENGLISH)) - : emailBodyHtml; + String htmlContent = + emailBodyHtml.endsWith(".html") + ? i18nHelper.loadHtmlFromClasspath( + emailBodyHtml, i18nHelper.getI18nTexts(Locale.ENGLISH)) + : emailBodyHtml; if (htmlContent != null) { for (Map.Entry entry : props.entrySet()) { htmlContent = htmlContent.replace("{{" + entry.getKey() + "}}", entry.getValue()); } int bodyStart = htmlContent.toLowerCase().indexOf(""); - String bodyContent = (bodyStart >= 0 && bodyEnd > bodyStart) - ? htmlContent.substring(htmlContent.indexOf('>', bodyStart) + 1, bodyEnd) - : htmlContent; + String bodyContent = + (bodyStart >= 0 && bodyEnd > bodyStart) + ? htmlContent.substring(htmlContent.indexOf('>', bodyStart) + 1, bodyEnd) + : htmlContent; logger.info( "│ Body: {}", bodyContent.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim()); @@ -119,8 +121,7 @@ public void postNotifications(EventContext context) { context.setCompleted(); } - private String renderTemplate( - CdsEvent event, String annotationPath, Map props) { + private String renderTemplate(CdsEvent event, String annotationPath, Map props) { Map i18nTexts = i18nHelper.getI18nTexts(Locale.ENGLISH); String template = i18nHelper.resolveAnnotationValue(event, annotationPath, i18nTexts); if (template == null) { From 7a6ebe3cb427438563ec6d9bcea8a7b698339518 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Thu, 25 Jun 2026 12:50:17 +0200 Subject: [PATCH 23/27] fix: map Locale.ROOT ('und'=undefined) to 'en' in getAvailableLocalesForEvent as 'und' is not a valid ANS language code --- .../com/sap/cds/notifications/helpers/I18nHelper.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java index cc81cae..727d7d0 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java @@ -69,8 +69,14 @@ public Set getAvailableLocalesForEvent(CdsEvent event) { Set filtered = new LinkedHashSet<>(); for (Locale locale : allLocales) { - // Always keep root ("und" = fallback for unknown locales) and English - if (locale.getLanguage().isEmpty() || "en".equals(locale.getLanguage())) { + // Locale.ROOT produces "und" (undefined) in BCP-47, which represents the default + // i18n.properties file. Map it to English since the content is the same and "und" + // is not a valid ANS language code. + if (locale.getLanguage().isEmpty()) { + filtered.add(Locale.ENGLISH); + continue; + } + if ("en".equals(locale.getLanguage())) { filtered.add(locale); continue; } From 26d45f36ade0268a54e0b1874be928c124cc60bd Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Thu, 25 Jun 2026 13:56:31 +0200 Subject: [PATCH 24/27] fix: improve 400 error message to mention ANS field length limits --- .../handlers/NotificationTemplateAutoProvisionerHandler.java | 4 ++-- .../handlers/NotificationTypeAutoProvisionerHandler.java | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java index baa598d..f86bcd9 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java @@ -116,13 +116,13 @@ private void createTemplate(NotificationTemplates template) { if (errorMsg.contains("400")) { logger.error( "ANS rejected standalone template '{}' with 400 Bad Request. " - + "Check that all required fields (Title in Translation) are set. Error: {}", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: {}", template.getKey(), errorMsg); throw new IllegalStateException( String.format( "ANS rejected standalone template '%s' with 400 Bad Request. " - + "Ensure @notification.template annotations are properly configured. Error: %s", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: %s", template.getKey(), errorMsg), e); } diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java index a14076f..1e99e73 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java @@ -137,15 +137,14 @@ private void createNotificationType(NotificationTypes notificationType) { if (errorMsg.contains("400")) { logger.error( "ANS rejected NotificationType '{}' with 400 Bad Request. " - + "This usually means required fields are missing or invalid. " - + "Check that all required fields (publicTitle, title, groupedTitle, subtitle) are set. " + + "Check that all required fields are set and field values do not exceed ANS length limits. " + "Error: {}", notificationType.getNotificationTypeKey(), errorMsg); throw new IllegalStateException( String.format( "ANS rejected NotificationType '%s' with 400 Bad Request. " - + "Ensure all required template fields are properly configured in your CDS model and i18n files. Error: %s", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: %s", notificationType.getNotificationTypeKey(), errorMsg), e); } From 2f0d05218dd1ef26813afd2d516afa9039322532 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 26 Jun 2026 14:12:49 +0200 Subject: [PATCH 25/27] refactor: switch NotificationType re-provisioning from delete+create back to PATCH to eliminate infinite loop risk --- ...otificationTypeAutoProvisionerHandler.java | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java index 1e99e73..044a6c9 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java @@ -7,9 +7,9 @@ import cds.gen.notificationtypeproviderservice.NotificationTypes; import cds.gen.notificationtypeproviderservice.NotificationTypes_; import com.sap.cds.notifications.assemblers.NotificationTypeAssembler; -import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; @@ -160,29 +160,40 @@ private void createNotificationType(NotificationTypes notificationType) { private void updateNotificationType( NotificationTypes notificationType, String notificationTypeId) { logger.debug( - "Updating NotificationType '{}' (id={}) via delete+create", + "Updating NotificationType '{}' (id={}) via PATCH", notificationType.getNotificationTypeKey(), notificationTypeId); try { - // ANS does not allow mixing Translations and Templates on update, - // so we delete the existing type and recreate it cleanly. + notificationType.setNotificationTypeId(notificationTypeId); notificationTypeProviderService.run( - Delete.from(NotificationTypes_.class) - .where(nt -> nt.NotificationTypeId().eq(notificationTypeId))); + Update.entity(NotificationTypes_.CDS_NAME).data(notificationType)); logger.debug( - "Deleted existing NotificationType '{}' (id={})", - notificationType.getNotificationTypeKey(), - notificationTypeId); + "NotificationType '{}' updated in ANS successfully", + notificationType.getNotificationTypeKey()); } catch (Exception e) { - logger.warn( - "Failed to delete existing NotificationType '{}' (id={}): {}. Attempting create anyway.", + String errorMsg = e.getMessage() != null ? e.getMessage() : ""; + if (errorMsg.contains("400")) { + logger.error( + "ANS rejected NotificationType update '{}' with 400 Bad Request. " + + "Check that all required fields are set and field values do not exceed ANS length limits. " + + "Error: {}", + notificationType.getNotificationTypeKey(), + errorMsg); + throw new IllegalStateException( + String.format( + "ANS rejected NotificationType update '%s' with 400 Bad Request. " + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: %s", + notificationType.getNotificationTypeKey(), errorMsg), + e); + } + logger.error( + "Failed to update NotificationType '{}' (id={})", notificationType.getNotificationTypeKey(), notificationTypeId, - e.getMessage()); + e); + throw e; } - - createNotificationType(notificationType); } } From 939ea29095a73afc5404e82ca25fe7ffff975aa1 Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 26 Jun 2026 14:36:37 +0200 Subject: [PATCH 26/27] test: update NotificationTypeProvisioningTest to verify PATCH keeps IDs stable instead of delete+create --- .../NotificationTypeProvisioningTest.java | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java index c53bdf1..fd352ff 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java @@ -86,77 +86,73 @@ void testEachNotificationTypeHasUniqueId() { } // ────────────────────────────────────────────────────────────── - // Test 2: Re-provisioning uses delete+create (new IDs assigned) + // Test 2: Re-provisioning uses PATCH (IDs remain stable) // ────────────────────────────────────────────────────────────── @Test void testReProvisioningUpdatesEachTypeCorrectly() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should delete and recreate each type"); + LOG.debug("Test: Re-provisioning should PATCH each existing type without changing IDs"); LOG.debug("=========================================="); - Map deletesBefore = new HashMap<>(); - for (String key : EXPECTED_KEYS) { - deletesBefore.put(key, NotificationTypeProviderServiceMockHandler.getDeleteCount(key)); + Map idsBefore = new HashMap<>(); + for (NotificationTypes nt : + NotificationTypeProviderServiceMockHandler.getAllNotificationTypes()) { + idsBefore.put(nt.getNotificationTypeKey(), nt.getNotificationTypeId()); } assertEquals( - EXPECTED_KEYS.size(), - NotificationTypeProviderServiceMockHandler.getNotificationTypeCount(), - "All types should exist before re-provisioning"); + EXPECTED_KEYS.size(), idsBefore.size(), "All types should exist before re-provisioning"); createProvisioner().onApplicationPrepared(); - for (String key : EXPECTED_KEYS) { - int deletesAfter = NotificationTypeProviderServiceMockHandler.getDeleteCount(key); - assertEquals( - deletesBefore.get(key) + 1, - deletesAfter, - "NotificationType '" + key + "' should have been deleted once during re-provisioning"); + Map idsAfter = new HashMap<>(); + for (NotificationTypes nt : + NotificationTypeProviderServiceMockHandler.getAllNotificationTypes()) { + idsAfter.put(nt.getNotificationTypeKey(), nt.getNotificationTypeId()); } assertEquals( - EXPECTED_KEYS.size(), - NotificationTypeProviderServiceMockHandler.getNotificationTypeCount(), - "All types should still exist after re-provisioning (delete+create)"); + idsBefore, + idsAfter, + "NotificationTypeIds should remain the same after re-provisioning (PATCH, not INSERT)"); - LOG.debug("Re-provisioning verified — all types were deleted and recreated"); + LOG.debug("Re-provisioning verified — all types retain their IDs"); } // ────────────────────────────────────────────────────────────── - // Test 3: Re-provisioning deletes and recreates all types + // Test 3: Re-provisioning updates all types via PATCH // ────────────────────────────────────────────────────────────── @Test void testReProvisioningUpdatesAllTypes() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should trigger DELETE+CREATE for each existing type"); + LOG.debug("Test: Re-provisioning should trigger PATCH for each existing type"); LOG.debug("=========================================="); - Map deletesBefore = new HashMap<>(); + Map updatesBefore = new HashMap<>(); for (String key : EXPECTED_KEYS) { - deletesBefore.put(key, NotificationTypeProviderServiceMockHandler.getDeleteCount(key)); + updatesBefore.put(key, NotificationTypeProviderServiceMockHandler.getUpdateCount(key)); } createProvisioner().onApplicationPrepared(); for (String key : EXPECTED_KEYS) { - int before = deletesBefore.get(key); - int after = NotificationTypeProviderServiceMockHandler.getDeleteCount(key); + int before = updatesBefore.get(key); + int after = NotificationTypeProviderServiceMockHandler.getUpdateCount(key); assertEquals( before + 1, after, "NotificationType '" + key - + "' should have been deleted exactly once. Before: " + + "' should have been updated exactly once. Before: " + before + ", After: " + after); - LOG.debug("Type '{}': delete count {} → {}", key, before, after); + LOG.debug("Type '{}': update count {} → {}", key, before, after); } - LOG.debug( - "All {} types were deleted and recreated during re-provisioning", EXPECTED_KEYS.size()); + LOG.debug("All {} types were updated via PATCH during re-provisioning", EXPECTED_KEYS.size()); } } From 3fd9463c640de0d9fa82d50dedcfc95c298c8e4a Mon Sep 17 00:00:00 2001 From: Buse Halis Date: Fri, 26 Jun 2026 17:36:04 +0200 Subject: [PATCH 27/27] fix: prevent infinite loop in NotificationTemplate re-provisioning by skipping update on 409 --- .../NotificationTemplateAutoProvisionerHandler.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java index f86bcd9..8581747 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java @@ -105,11 +105,12 @@ private void createTemplate(NotificationTemplates template) { String errorMsg = e.getMessage() != null ? e.getMessage() : ""; if (errorMsg.contains("409")) { - // Race condition: template was created between our GET and INSERT - logger.debug( - "Standalone template '{}' was created concurrently (409). Attempting update...", + // Template was created concurrently (race condition) — skip to avoid an + // update loop (updateTemplate would DELETE+CREATE, and if DELETE keeps + // failing the CREATE would 409 again, causing an infinite loop). + logger.warn( + "Standalone template '{}' already exists (409). Skipping to avoid update loop.", template.getKey()); - updateTemplate(template); return; }