From 7b599e030cee59be1adebc6a346a842a26697d42 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 20 Aug 2026 15:23:19 +0200 Subject: [PATCH 01/24] initial commit --- cds-plugin.js | 27 +++++++- lib/compile.js | 14 +++++ lib/utils.js | 62 +++++++++++++++++++ .../db/data/sap.capire.bookshop-Authors.csv | 2 +- tests/bookshop/srv/notifications.cds | 13 ++++ 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index b75b06d..b1d026c 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -1,7 +1,7 @@ const cds = require('@sap/cds') if (!cds.env.requires?.notifications?.enabled) return -const { buildNotificationFromEvent } = require('./lib/utils') +const { buildNotificationFromEvent, replaceRefsInExpr, buildNotificationFromEntity } = require('./lib/utils') cds.build?.register?.('notifications', require("./lib/build")) cds.on("loaded", m => { @@ -32,6 +32,29 @@ cds.on('serving', service => { } return next() }) + service.after("*", async (results, req) => { + const notificationsList = req.target?.['@notifications'] + if (!notificationsList?.length) return + const matching = notificationsList.filter(n => n.on?.includes(req.event)) + if (!matching.length) return + const notifications = await cds.connect.to('notifications') + for (const n of matching) { + if (n.where) { + const where = n.where.xpr.map(token => + token?.ref?.[0] === '$self' ? { ref: token.ref.slice(1) } : token + ) + const exists = await SELECT.one.from(req.target).where(where) + if (!exists) continue + } + const notification = await buildNotificationFromEntity(n, results) + try { + await notifications.notify(notification) + } catch (err) { + const LOG = cds.log('notifications') + LOG._error && LOG.error('Failed to send notification for entity', n.type, err) + } + } + }) }) cds.once("served", async () => { @@ -63,4 +86,4 @@ cds.once("served", async () => { } require("@sap-cloud-sdk/util").setGlobalLogLevel("error") -}) +}) \ No newline at end of file diff --git a/lib/compile.js b/lib/compile.js index eacce3b..33fb7c5 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -72,6 +72,20 @@ function notificationTypesFromModel(model) { types.push(type) } + for (const def of Object.values(model.definitions)) { + if (def.kind !== 'entity') continue + const notifications = def['@notifications'] + if (!notifications?.length) continue + for (const entry of Object.values(notifications)) { + if (!entry.type) continue + types.push({ + NotificationTypeKey: entry.type, + NotificationTypeVersion: '1', + Templates: [{ Language: defaultTexts, TemplateLanguage: 'mustache', TemplateSensitive: entry.type }], + }) + } + } + return types } diff --git a/lib/utils.js b/lib/utils.js index 72f8207..89c4b77 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -326,6 +326,67 @@ async function buildNotificationFromEvent(eventDef, data = {}) { return notification } +function resolveRecipients(recipients, data) { + if (!recipients) return [] + if (typeof recipients === 'object' && !recipients.ref) { + return Object.values(recipients).flatMap(item => resolveRecipients(item, data)) + } + if (recipients.ref) { + const prop = recipients.ref[0] === '$self' ? recipients.ref[1] : recipients.ref[0] + const rows = Array.isArray(data) ? data : [data] + return [...new Set(rows.map(row => row[prop]).filter(Boolean))] + } + return [String(recipients)] +} + +async function buildNotificationFromEntity(hook, data = {}) { + // hook = one entry from the @notifications array on the entity, e.g. { type, on, recipients, where, parameters, priority } + + const Recipients = resolveRecipients(hook.recipients, data).map(id => ({ [getRecipientKey(id)]: id })) + + // If hook.parameters is specified, use it directly; otherwise auto-map all entity data properties by name + const Properties = hook.parameters + ? Object.entries(hook.parameters).map(([key, value]) => ({ + Key: key, + Language: cds.env.i18n?.default_language ?? 'en', + Value: String(data[value.ref[0]]), + Type: 'String', + IsSensitive: true, + })) + : Object.entries(data).map(([key, value]) => ({ + Key: key, + Language: cds.env.i18n?.default_language ?? 'en', + Value: String(value ?? ''), + Type: 'String', + IsSensitive: true, + })) + + // Priority lives inside the hook itself (not on the entity def like events) + const priorityAnnotation = hook.priority + let Priority = 'NEUTRAL' + if (priorityAnnotation) { + if (priorityAnnotation.xpr) { + Priority = await evaluateDynamicPriority({ xpr: priorityAnnotation.xpr }, data) ?? 'NEUTRAL' + } else { + const raw = resolveEnumValue(priorityAnnotation) + if (raw) { + const priority = String(raw).toUpperCase() + Priority = validatePriority(priority) ? priority : 'NEUTRAL' + } + } + } + + const notification = { + NotificationTypeKey: hook.type, + NotificationTypeVersion: '1', + Priority, + Properties, + Recipients, + } + + return notification +} + function applyValueLengthConstraints(notification) { if (!notification) return notification @@ -400,6 +461,7 @@ module.exports = { getNotificationTypesKeyWithPrefix, buildNotification, buildNotificationFromEvent, + buildNotificationFromEntity, mapCdsTypeToANSType, replaceRefsInExpr, applyValueLengthConstraints, diff --git a/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv b/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv index 9b418c1..93a4d57 100644 --- a/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv +++ b/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv @@ -1,5 +1,5 @@ ID,name,dateOfBirth,placeOfBirth,dateOfDeath,placeOfDeath 101,Emily Brontë,1818-07-30,"Thornton, Yorkshire",1848-12-19,"Haworth, Yorkshire" 107,Charlotte Brontë,1818-04-21,"Thornton, Yorkshire",1855-03-31,"Haworth, Yorkshire" -150,Edgar Allen Poe,1809-01-19,"Boston, Massachusetts",1849-10-07,"Baltimore, Maryland" +150,Edgar Allan Poe,1809-01-19,"Boston, Massachusetts",1849-10-07,"Baltimore, Maryland" 170,Richard Carpenter,1929-08-14,"King’s Lynn, Norfolk",2012-02-26,"Hertfordshire, England" diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index dc19bce..002e5b5 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -1,4 +1,5 @@ using { CatalogService } from './cat-service'; +using {sap.capire.bookshop as my} from '../db/schema'; extend service CatalogService with { @description: '{i18n>BOOK_ORDERED_DESCRIPTION}' @@ -38,3 +39,15 @@ extend service CatalogService with { recipients : array of String; } } + +service CatalogTest { + @notifications : [{ + type: 'MY_NOTIFICATION_TYPE', + on: ['READ', 'CREATE'], + recipients: ($self.createdBy), + where: ($self.title = 'Wuthering Heights'), + priority: #Low, + }] + entity Books as projection on my.Books; + +} From b8c4771cfc64484adc99eb8a5e66c855dd1adf99 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 26 Aug 2026 17:00:50 +0200 Subject: [PATCH 02/24] fix: keep empty template empty --- lib/compile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compile.js b/lib/compile.js index 74ba35c..e354f34 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -84,7 +84,7 @@ function notificationTypesFromModel(model) { types.push({ NotificationTypeKey: entry.type, NotificationTypeVersion: '1', - Templates: [{ Language: defaultTexts, TemplateLanguage: 'mustache', TemplateSensitive: entry.type }], + Templates: [{ Language: defaultTexts, TemplateLanguage: 'mustache' }], }) } } From 430dcc6643278c0260e568085de5c5f31bc0f526 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 26 Aug 2026 17:06:46 +0200 Subject: [PATCH 03/24] prettier --- cds-plugin.js | 18 ++++++++---------- lib/compile.js | 8 ++++---- lib/utils.js | 28 ++++++++++++++-------------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index e9da8dd..4d41d9a 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -1,8 +1,8 @@ const cds = require("@sap/cds") if (!cds.env.requires?.notifications?.enabled) return -const { buildNotificationFromEvent, replaceRefsInExpr, buildNotificationFromEntity } = require('./lib/utils') -cds.build?.register?.('notifications', require("./lib/build")) +const { buildNotificationFromEvent, replaceRefsInExpr, buildNotificationFromEntity } = require("./lib/utils") +cds.build?.register?.("notifications", require("./lib/build")) cds.on("loaded", m => { for (const def of Object.values(m.definitions)) { @@ -33,16 +33,14 @@ cds.on("serving", service => { return next() }) service.after("*", async (results, req) => { - const notificationsList = req.target?.['@notifications'] + const notificationsList = req.target?.["@notifications"] if (!notificationsList?.length) return const matching = notificationsList.filter(n => n.on?.includes(req.event)) if (!matching.length) return - const notifications = await cds.connect.to('notifications') + const notifications = await cds.connect.to("notifications") for (const n of matching) { if (n.where) { - const where = n.where.xpr.map(token => - token?.ref?.[0] === '$self' ? { ref: token.ref.slice(1) } : token - ) + const where = n.where.xpr.map(token => (token?.ref?.[0] === "$self" ? { ref: token.ref.slice(1) } : token)) const exists = await SELECT.one.from(req.target).where(where) if (!exists) continue } @@ -50,8 +48,8 @@ cds.on("serving", service => { try { await notifications.notify(notification) } catch (err) { - const LOG = cds.log('notifications') - LOG._error && LOG.error('Failed to send notification for entity', n.type, err) + const LOG = cds.log("notifications") + LOG._error && LOG.error("Failed to send notification for entity", n.type, err) } } }) @@ -86,4 +84,4 @@ cds.once("served", async () => { } require("@sap-cloud-sdk/util").setGlobalLogLevel("error") -}) \ No newline at end of file +}) diff --git a/lib/compile.js b/lib/compile.js index e354f34..49b3f86 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -76,15 +76,15 @@ function notificationTypesFromModel(model) { } for (const def of Object.values(model.definitions)) { - if (def.kind !== 'entity') continue - const notifications = def['@notifications'] + if (def.kind !== "entity") continue + const notifications = def["@notifications"] if (!notifications?.length) continue for (const entry of Object.values(notifications)) { if (!entry.type) continue types.push({ NotificationTypeKey: entry.type, - NotificationTypeVersion: '1', - Templates: [{ Language: defaultTexts, TemplateLanguage: 'mustache' }], + NotificationTypeVersion: "1", + Templates: [{ Language: defaultTexts, TemplateLanguage: "mustache" }] }) } } diff --git a/lib/utils.js b/lib/utils.js index 008aa3f..3d7dc9c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -339,11 +339,11 @@ async function buildNotificationFromEvent(eventDef, data = {}) { function resolveRecipients(recipients, data) { if (!recipients) return [] - if (typeof recipients === 'object' && !recipients.ref) { + if (typeof recipients === "object" && !recipients.ref) { return Object.values(recipients).flatMap(item => resolveRecipients(item, data)) } if (recipients.ref) { - const prop = recipients.ref[0] === '$self' ? recipients.ref[1] : recipients.ref[0] + const prop = recipients.ref[0] === "$self" ? recipients.ref[1] : recipients.ref[0] const rows = Array.isArray(data) ? data : [data] return [...new Set(rows.map(row => row[prop]).filter(Boolean))] } @@ -359,40 +359,40 @@ async function buildNotificationFromEntity(hook, data = {}) { const Properties = hook.parameters ? Object.entries(hook.parameters).map(([key, value]) => ({ Key: key, - Language: cds.env.i18n?.default_language ?? 'en', + Language: cds.env.i18n?.default_language ?? "en", Value: String(data[value.ref[0]]), - Type: 'String', - IsSensitive: true, + Type: "String", + IsSensitive: true })) : Object.entries(data).map(([key, value]) => ({ Key: key, - Language: cds.env.i18n?.default_language ?? 'en', - Value: String(value ?? ''), - Type: 'String', - IsSensitive: true, + Language: cds.env.i18n?.default_language ?? "en", + Value: String(value ?? ""), + Type: "String", + IsSensitive: true })) // Priority lives inside the hook itself (not on the entity def like events) const priorityAnnotation = hook.priority - let Priority = 'NEUTRAL' + let Priority = "NEUTRAL" if (priorityAnnotation) { if (priorityAnnotation.xpr) { - Priority = await evaluateDynamicPriority({ xpr: priorityAnnotation.xpr }, data) ?? 'NEUTRAL' + Priority = (await evaluateDynamicPriority({ xpr: priorityAnnotation.xpr }, data)) ?? "NEUTRAL" } else { const raw = resolveEnumValue(priorityAnnotation) if (raw) { const priority = String(raw).toUpperCase() - Priority = validatePriority(priority) ? priority : 'NEUTRAL' + Priority = validatePriority(priority) ? priority : "NEUTRAL" } } } const notification = { NotificationTypeKey: hook.type, - NotificationTypeVersion: '1', + NotificationTypeVersion: "1", Priority, Properties, - Recipients, + Recipients } return notification From 59642f59bfe424d656ea20228b22039175b22dfa Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 26 Aug 2026 17:30:23 +0200 Subject: [PATCH 04/24] remove request_target --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01f8af6..1f9de1c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: ["main"] - pull_request_target: + pull_request: branches: ["main"] types: [reopened, synchronize, opened] From 61c9757565b857df4669ec815be45cdc07764334 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 26 Aug 2026 17:34:02 +0200 Subject: [PATCH 05/24] remove approval --- .github/workflows/test.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f9de1c..1331d31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,19 +12,8 @@ permissions: contents: read jobs: - requires-approval: - runs-on: ubuntu-latest - name: "Waiting for PR approval as this workflow runs on pull_request_target" - if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.owner.login != 'cap-js' - environment: pr-approval - steps: - - name: Approval Step - run: echo "This job has been approved!" - test: runs-on: ubuntu-latest - needs: requires-approval - if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') strategy: fail-fast: false matrix: @@ -42,8 +31,6 @@ jobs: integration-tests: runs-on: ubuntu-latest - needs: requires-approval - if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') name: Integration Tests on Node.js ${{ matrix.node-version }} strategy: fail-fast: false From 36b1c169006c0bec648ff297097d5457e77e64d1 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 09:08:59 +0200 Subject: [PATCH 06/24] retrigger CI From 25489870c223325e555dd9b1a3412b314054a775 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 09:10:02 +0200 Subject: [PATCH 07/24] revert --- .github/workflows/test.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1331d31..01f8af6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: ["main"] - pull_request: + pull_request_target: branches: ["main"] types: [reopened, synchronize, opened] @@ -12,8 +12,19 @@ permissions: contents: read jobs: + requires-approval: + runs-on: ubuntu-latest + name: "Waiting for PR approval as this workflow runs on pull_request_target" + if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.owner.login != 'cap-js' + environment: pr-approval + steps: + - name: Approval Step + run: echo "This job has been approved!" + test: runs-on: ubuntu-latest + needs: requires-approval + if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') strategy: fail-fast: false matrix: @@ -31,6 +42,8 @@ jobs: integration-tests: runs-on: ubuntu-latest + needs: requires-approval + if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') name: Integration Tests on Node.js ${{ matrix.node-version }} strategy: fail-fast: false From a09b5335a5e929cb040fde8e250ad3713764d1a4 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 10:27:02 +0200 Subject: [PATCH 08/24] test: empty event --- tests/bookshop/srv/notifications.cds | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index 34d47e6..efeba2b 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -37,13 +37,18 @@ extend service CatalogService with { } service CatalogTest { - @notifications : [{ - type: 'MY_NOTIFICATION_TYPE', - on: ['READ', 'CREATE'], - recipients: ($self.createdBy), - where: ($self.title = 'Wuthering Heights'), - priority: #Low, - }] - entity Books as projection on my.Books; +// @notifications : [{ +// type: 'MY_NOTIFICATION_TYPE', +// on: ['READ', 'CREATE'], +// recipients: ($self.createdBy), +// where: ($self.title = 'Wuthering Heights'), +// priority: #Low, +// }] +// entity Books as projection on my.Books; + +@notification.title: '' +event EmptyNotify { + recipients: array of String; +} } From 1b54e7a8bb9facb9827d3dc19b65ce374c0c8516 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 11:15:07 +0200 Subject: [PATCH 09/24] add: empty template test --- lib/notificationTypes.js | 4 ++++ tests/bookshop/srv/notifications.cds | 10 ++-------- tests/integration/bookshop.test.js | 9 +++++++++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index f422ced..682a983 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -77,6 +77,10 @@ async function createNotificationType(notificationType) { LOG.warn( `Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} was not found. Creating it...` ) + const hasTemplate = notificationType.Templates?.some(t => t.TemplateSensitive || t.TemplatePublic || t.TemplateGrouped) + if (!hasTemplate) { + throw new Error(`Notification type '${notificationType.NotificationTypeKey}' has no template titles. At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation.`) + } let response try { diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index efeba2b..40e9218 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -36,7 +36,7 @@ extend service CatalogService with { } } -service CatalogTest { +// service CatalogTest { // @notifications : [{ // type: 'MY_NOTIFICATION_TYPE', // on: ['READ', 'CREATE'], @@ -45,10 +45,4 @@ service CatalogTest { // priority: #Low, // }] // entity Books as projection on my.Books; - -@notification.title: '' -event EmptyNotify { - recipients: array of String; -} - -} +// } diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 004c24b..e9ca434 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -2,6 +2,7 @@ const cds = require("@sap/cds") const { join } = require("path") const { messages } = require("../../lib/utils") const { notificationTypesFromModel } = require("../../lib/compile") +const { processNotificationTypes } = require("../../lib/notificationTypes") const usesRestService = ["hybrid", "production"].includes(process.env.CDS_ENV) @@ -254,6 +255,14 @@ describe("Notifications Integration", () => { "Event 'OversizedEvent' has elements exceeding the maximum key length of 128 characters" ) }) + + test("Throws clear error when deploying a notification type with empty templates", async () => { + if (!usesRestService) return + const emptyType = { NotificationTypeKey: "EmptyType", NotificationTypeVersion: "1", Templates: [{ Language: "en", TemplateLanguage: "mustache" }] } + await expect(processNotificationTypes([emptyType])).rejects.toThrow( + "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation." + ) + }) }) test("Batch of typed notifications logs each one to console", async () => { From 1cd85e954b7093775201233329f0e803315eab61 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 11:34:27 +0200 Subject: [PATCH 10/24] fix: ANS override test prevention --- lib/notificationTypes.js | 1 + tests/integration/bookshop.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index 682a983..78afca2 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -333,5 +333,6 @@ async function processNotificationTypes(notificationTypesJSON) { module.exports = { createNotificationTypesMap, + createNotificationType, processNotificationTypes } diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index e9ca434..7b94f53 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -2,7 +2,7 @@ const cds = require("@sap/cds") const { join } = require("path") const { messages } = require("../../lib/utils") const { notificationTypesFromModel } = require("../../lib/compile") -const { processNotificationTypes } = require("../../lib/notificationTypes") +const { createNotificationType } = require("../../lib/notificationTypes") const usesRestService = ["hybrid", "production"].includes(process.env.CDS_ENV) @@ -259,7 +259,7 @@ describe("Notifications Integration", () => { test("Throws clear error when deploying a notification type with empty templates", async () => { if (!usesRestService) return const emptyType = { NotificationTypeKey: "EmptyType", NotificationTypeVersion: "1", Templates: [{ Language: "en", TemplateLanguage: "mustache" }] } - await expect(processNotificationTypes([emptyType])).rejects.toThrow( + await expect(createNotificationType(emptyType)).rejects.toThrow( "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation." ) }) @@ -343,7 +343,7 @@ describe("Notifications Integration", () => { alert._handlers.before.splice(beforeHandlers) }) - test("is called before a notification is sent and receives msg.event and msg.data", async () => { + test("Is called before a notification is sent and receives msg.event and msg.data", async () => { let capturedEvent, capturedData alert.before("*", msg => { capturedEvent = msg.event @@ -362,7 +362,7 @@ describe("Notifications Integration", () => { }) }) - test("can suppress a notification by throwing", async () => { + test("Can suppress a notification by throwing", async () => { alert.before("*", () => { throw new cds.error("Recipient not eligible") }) From 69f7af5f7ae5a1acbae7c3dd5e95e8be34b4f3e6 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 11:38:48 +0200 Subject: [PATCH 11/24] test: confirm integration test is working --- tests/integration/bookshop.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 7b94f53..0259cc8 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -257,7 +257,6 @@ describe("Notifications Integration", () => { }) test("Throws clear error when deploying a notification type with empty templates", async () => { - if (!usesRestService) return const emptyType = { NotificationTypeKey: "EmptyType", NotificationTypeVersion: "1", Templates: [{ Language: "en", TemplateLanguage: "mustache" }] } await expect(createNotificationType(emptyType)).rejects.toThrow( "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation." From 87dcf6b5797f21af5618015307eb2aca00dea984 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 11:58:15 +0200 Subject: [PATCH 12/24] revert: tests confirmed --- tests/integration/bookshop.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 0259cc8..7b94f53 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -257,6 +257,7 @@ describe("Notifications Integration", () => { }) test("Throws clear error when deploying a notification type with empty templates", async () => { + if (!usesRestService) return const emptyType = { NotificationTypeKey: "EmptyType", NotificationTypeVersion: "1", Templates: [{ Language: "en", TemplateLanguage: "mustache" }] } await expect(createNotificationType(emptyType)).rejects.toThrow( "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation." From 0adf7df97a396ffbc79b3315b2a226d065a6ae7d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 12:05:37 +0200 Subject: [PATCH 13/24] fix: no empty entity template --- lib/compile.js | 10 +++++++++- lib/notificationTypes.js | 8 ++++++-- tests/bookshop/srv/notifications.cds | 21 +++++++++++---------- tests/integration/bookshop.test.js | 6 +++++- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/lib/compile.js b/lib/compile.js index 49b3f86..594e13f 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -84,7 +84,15 @@ function notificationTypesFromModel(model) { types.push({ NotificationTypeKey: entry.type, NotificationTypeVersion: "1", - Templates: [{ Language: defaultTexts, TemplateLanguage: "mustache" }] + Templates: [ + { + Language: defaultTexts, + TemplateLanguage: "mustache", + TemplateSensitive: entry.type, + TemplatePublic: entry.type, + TemplateGrouped: entry.type + } + ] }) } } diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index 78afca2..4760fc2 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -77,9 +77,13 @@ async function createNotificationType(notificationType) { LOG.warn( `Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} was not found. Creating it...` ) - const hasTemplate = notificationType.Templates?.some(t => t.TemplateSensitive || t.TemplatePublic || t.TemplateGrouped) + const hasTemplate = notificationType.Templates?.some( + t => t.TemplateSensitive || t.TemplatePublic || t.TemplateGrouped + ) if (!hasTemplate) { - throw new Error(`Notification type '${notificationType.NotificationTypeKey}' has no template titles. At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation.`) + throw new Error( + `Notification type '${notificationType.NotificationTypeKey}' has no template titles. At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation.` + ) } let response diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index 40e9218..34d47e6 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -36,13 +36,14 @@ extend service CatalogService with { } } -// service CatalogTest { -// @notifications : [{ -// type: 'MY_NOTIFICATION_TYPE', -// on: ['READ', 'CREATE'], -// recipients: ($self.createdBy), -// where: ($self.title = 'Wuthering Heights'), -// priority: #Low, -// }] -// entity Books as projection on my.Books; -// } +service CatalogTest { + @notifications : [{ + type: 'MY_NOTIFICATION_TYPE', + on: ['READ', 'CREATE'], + recipients: ($self.createdBy), + where: ($self.title = 'Wuthering Heights'), + priority: #Low, + }] + entity Books as projection on my.Books; + +} diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 7b94f53..67badf2 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -258,7 +258,11 @@ describe("Notifications Integration", () => { test("Throws clear error when deploying a notification type with empty templates", async () => { if (!usesRestService) return - const emptyType = { NotificationTypeKey: "EmptyType", NotificationTypeVersion: "1", Templates: [{ Language: "en", TemplateLanguage: "mustache" }] } + const emptyType = { + NotificationTypeKey: "EmptyType", + NotificationTypeVersion: "1", + Templates: [{ Language: "en", TemplateLanguage: "mustache" }] + } await expect(createNotificationType(emptyType)).rejects.toThrow( "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation." ) From c555bbb84b63dde873f6611b2d41b7531cd00aee Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 12:34:43 +0200 Subject: [PATCH 14/24] add: entity unit tests --- lib/compile.js | 1 + lib/utils.js | 1 + tests/unit/lib/entityUnitNotify.test.js | 248 ++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 tests/unit/lib/entityUnitNotify.test.js diff --git a/lib/compile.js b/lib/compile.js index 594e13f..0c70514 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -81,6 +81,7 @@ function notificationTypesFromModel(model) { if (!notifications?.length) continue for (const entry of Object.values(notifications)) { if (!entry.type) continue + if (types.some(t => t.NotificationTypeKey === entry.type)) continue types.push({ NotificationTypeKey: entry.type, NotificationTypeVersion: "1", diff --git a/lib/utils.js b/lib/utils.js index 3d7dc9c..36f4c67 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -480,6 +480,7 @@ module.exports = { buildNotification, buildNotificationFromEvent, buildNotificationFromEntity, + resolveRecipients, mapCdsTypeToANSType, replaceRefsInExpr, applyValueLengthConstraints, diff --git a/tests/unit/lib/entityUnitNotify.test.js b/tests/unit/lib/entityUnitNotify.test.js new file mode 100644 index 0000000..c9cb234 --- /dev/null +++ b/tests/unit/lib/entityUnitNotify.test.js @@ -0,0 +1,248 @@ +const cds = require("@sap/cds") +const { buildNotificationFromEntity, resolveRecipients } = require("../../../lib/utils") +const { notificationTypesFromModel } = require("../../../lib/compile") + +function makeModel(defs) { + return { definitions: Object.values(defs) } +} + +describe("resolveRecipients", () => { + test("Returns empty array for null", () => { + expect(resolveRecipients(null, {})).toEqual([]) + }) + + test("Returns empty array for undefined", () => { + expect(resolveRecipients(undefined, {})).toEqual([]) + }) + + test("Resolves $self.field ref from a single data object", () => { + const recipients = { ref: ["$self", "createdBy"] } + expect(resolveRecipients(recipients, { createdBy: "alice@example.com" })).toEqual(["alice@example.com"]) + }) + + test("Resolves plain field ref from a single data object", () => { + const recipients = { ref: ["createdBy"] } + expect(resolveRecipients(recipients, { createdBy: "alice@example.com" })).toEqual(["alice@example.com"]) + }) + + test("Resolves recipients across an array of rows and deduplicates", () => { + const recipients = { ref: ["$self", "createdBy"] } + const data = [ + { createdBy: "alice@example.com" }, + { createdBy: "bob@example.com" }, + { createdBy: "alice@example.com" } + ] + expect(resolveRecipients(recipients, data)).toEqual(["alice@example.com", "bob@example.com"]) + }) + + test("Filters out falsy values from data rows", () => { + const recipients = { ref: ["$self", "createdBy"] } + const data = [{ createdBy: "alice@example.com" }, { createdBy: null }, { createdBy: undefined }] + expect(resolveRecipients(recipients, data)).toEqual(["alice@example.com"]) + }) + + test("Returns string literal as a single-element array", () => { + expect(resolveRecipients("static@example.com", {})).toEqual(["static@example.com"]) + }) + + test("Recurses over object without .ref (e.g. CDS object-like annotation)", () => { + const recipients = { 0: { ref: ["createdBy"] }, 1: { ref: ["modifiedBy"] } } + const data = { createdBy: "alice@example.com", modifiedBy: "bob@example.com" } + const result = resolveRecipients(recipients, data) + expect(result).toContain("alice@example.com") + expect(result).toContain("bob@example.com") + }) +}) + +describe("buildNotificationFromEntity", () => { + const baseHook = { + type: "MY_NOTIFICATION_TYPE", + on: ["READ", "CREATE"], + recipients: { ref: ["$self", "createdBy"] } + } + + const baseData = { + ID: "201", + title: "Wuthering Heights", + createdBy: "alice@example.com" + } + + test("Sets NotificationTypeKey to hook.type (no prefix applied)", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.NotificationTypeKey).toBe("MY_NOTIFICATION_TYPE") + }) + + test("Sets NotificationTypeVersion to '1'", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.NotificationTypeVersion).toBe("1") + }) + + test("Auto-maps all entity data fields to Properties when hook.parameters is not set", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + const keys = result.Properties.map(p => p.Key) + expect(keys).toContain("ID") + expect(keys).toContain("title") + expect(keys).toContain("createdBy") + }) + + test("Properties entries have correct shape with IsSensitive true", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.Properties).toContainEqual({ + Key: "title", + Language: "en", + Value: "Wuthering Heights", + Type: "String", + IsSensitive: true + }) + }) + + test("Converts null/undefined property values to empty string", async () => { + const data = { title: null, stock: undefined } + const result = await buildNotificationFromEntity(baseHook, data) + const titleProp = result.Properties.find(p => p.Key === "title") + expect(titleProp.Value).toBe("") + }) + + test("Uses explicit hook.parameters when provided, mapped by ref", async () => { + const hook = { + ...baseHook, + parameters: { + bookTitle: { ref: ["title"] } + } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties).toHaveLength(1) + expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) + }) + + test("Defaults Priority to NEUTRAL when hook has no priority", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.Priority).toBe("NEUTRAL") + }) + + test("Resolves enum priority annotation (#Low -> LOW)", async () => { + const hook = { ...baseHook, priority: { "#": "Low" } } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Priority).toBe("LOW") + }) + + test("Resolves enum priority annotation (#High -> HIGH)", async () => { + const hook = { ...baseHook, priority: { "#": "High" } } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Priority).toBe("HIGH") + }) + + describe("Falls back to NEUTRAL for invalid priority", () => { + const log = cds.test.log() + beforeEach(() => log.clear()) + + test("warns and returns NEUTRAL", async () => { + const hook = { ...baseHook, priority: { "#": "CRITICAL" } } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Priority).toBe("NEUTRAL") + expect(log.output).toMatch(/invalid|CRITICAL/i) + }) + }) + + test("Resolves recipients from $self.field ref in data", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.Recipients).toContainEqual({ RecipientId: "alice@example.com" }) + }) + + test("Returns empty Recipients array when hook.recipients is not set", async () => { + const hook = { type: "MY_NOTIFICATION_TYPE", on: ["READ"] } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Recipients).toEqual([]) + }) + + test("Works with array data (multiple result rows)", async () => { + const data = [ + { title: "Book A", createdBy: "alice@example.com" }, + { title: "Book B", createdBy: "bob@example.com" } + ] + const result = await buildNotificationFromEntity(baseHook, data) + const recipientIds = result.Recipients.map(r => r.RecipientId) + expect(recipientIds).toContain("alice@example.com") + expect(recipientIds).toContain("bob@example.com") + }) +}) + +describe("notificationTypesFromModel — entity @notifications", () => { + test("Generates a type with TemplateSensitive/Public/Grouped set to entry.type", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ type: "MY_TYPE", on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + const type = types.find(t => t.NotificationTypeKey === "MY_TYPE") + expect(type).toBeDefined() + expect(type.NotificationTypeVersion).toBe("1") + expect(type.Templates[0].TemplateSensitive).toBe("MY_TYPE") + expect(type.Templates[0].TemplatePublic).toBe("MY_TYPE") + expect(type.Templates[0].TemplateGrouped).toBe("MY_TYPE") + }) + + test("Skips @notifications entries that have no type field", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + expect(types).toHaveLength(0) + }) + + test("Does not generate entity type when @notifications is empty", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [] + } + }) + const types = notificationTypesFromModel(model) + expect(types).toHaveLength(0) + }) + + test("Does not add a duplicate when an event with the same type key already exists", () => { + const model = makeModel({ + "Svc.MY_TYPE": { + kind: "event", + name: "Svc.MY_TYPE", + "@notification.title": "My Type Title" + }, + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ type: "MY_TYPE", on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + const matching = types.filter(t => t.NotificationTypeKey === "MY_TYPE") + expect(matching).toHaveLength(1) + // The event-derived entry should win (it has a real title) + expect(matching[0].Templates[0].TemplateSensitive).toBe("My Type Title") + }) + + test("Handles multiple @notifications entries on the same entity", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [ + { type: "TYPE_A", on: ["READ"] }, + { type: "TYPE_B", on: ["CREATE"] } + ] + } + }) + const types = notificationTypesFromModel(model) + const keys = types.map(t => t.NotificationTypeKey) + expect(keys).toContain("TYPE_A") + expect(keys).toContain("TYPE_B") + }) +}) From 823c7599bfc77dcd723eaae4de4e854fcf3f00e9 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 18:18:44 +0200 Subject: [PATCH 15/24] add: entity notification integration tests --- cds-plugin.js | 25 ++-- lib/utils.js | 18 +-- tests/integration/entityNotification.test.js | 114 +++++++++++++++++++ 3 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 tests/integration/entityNotification.test.js diff --git a/cds-plugin.js b/cds-plugin.js index 4d41d9a..1afa106 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -41,15 +41,26 @@ cds.on("serving", service => { for (const n of matching) { if (n.where) { const where = n.where.xpr.map(token => (token?.ref?.[0] === "$self" ? { ref: token.ref.slice(1) } : token)) - const exists = await SELECT.one.from(req.target).where(where) + const keyParams = req.params?.[0] + let checkWhere = where + if (keyParams) { + const keyFilter = Object.entries(keyParams).flatMap(([k, v], i) => + (i > 0 ? ["and"] : []).concat([{ ref: [k] }, "=", { val: v }]) + ) + checkWhere = [...keyFilter, "and", ...where] + } + const exists = await SELECT.one.from(req.target).where(checkWhere) if (!exists) continue } - const notification = await buildNotificationFromEntity(n, results) - try { - await notifications.notify(notification) - } catch (err) { - const LOG = cds.log("notifications") - LOG._error && LOG.error("Failed to send notification for entity", n.type, err) + const entities = Array.isArray(results) ? results : [results] + for (const entity of entities) { + const notification = await buildNotificationFromEntity(n, entity) + try { + await notifications.notify(notification) + } catch (err) { + const LOG = cds.log("notifications") + LOG._error && LOG.error("Failed to send notification for entity", n.type, err) + } } } }) diff --git a/lib/utils.js b/lib/utils.js index 36f4c67..fb4215a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -360,17 +360,19 @@ async function buildNotificationFromEntity(hook, data = {}) { ? Object.entries(hook.parameters).map(([key, value]) => ({ Key: key, Language: cds.env.i18n?.default_language ?? "en", - Value: String(data[value.ref[0]]), - Type: "String", - IsSensitive: true - })) - : Object.entries(data).map(([key, value]) => ({ - Key: key, - Language: cds.env.i18n?.default_language ?? "en", - Value: String(value ?? ""), + Value: String(data[value.ref[0]] ?? ""), Type: "String", IsSensitive: true })) + : Object.entries(data) + .filter(([, value]) => String(value ?? "").length <= MAX_PROPERTY_VALUE_LENGTH) + .map(([key, value]) => ({ + Key: key, + Language: cds.env.i18n?.default_language ?? "en", + Value: String(value ?? ""), + Type: "String", + IsSensitive: true + })) // Priority lives inside the hook itself (not on the entity def like events) const priorityAnnotation = hook.priority diff --git a/tests/integration/entityNotification.test.js b/tests/integration/entityNotification.test.js new file mode 100644 index 0000000..5850aa8 --- /dev/null +++ b/tests/integration/entityNotification.test.js @@ -0,0 +1,114 @@ +const cds = require("@sap/cds") +const { join } = require("path") + +const usesRestService = ["hybrid", "production"].includes(process.env.CDS_ENV) + +const { GET } = cds.test(join(__dirname, "../bookshop")) + +describe("Entity @notifications", () => { + let alert + let catalogTest + + beforeAll(async () => { + alert = await cds.connect.to("notifications") + catalogTest = await cds.connect.to("CatalogTest") + }) + + describe("Startup", () => { + test("MY_NOTIFICATION_TYPE is registered in cds.notifications.local.types", () => { + expect(cds.notifications?.local?.types).toBeDefined() + expect(cds.notifications.local.types).toHaveProperty("bookshop/MY_NOTIFICATION_TYPE") + }) + + test("MY_NOTIFICATION_TYPE template fields are set to the type key", () => { + const type = cds.notifications.local.types["bookshop/MY_NOTIFICATION_TYPE"]["1"] + expect(type.Templates[0].TemplateSensitive).toBe("MY_NOTIFICATION_TYPE") + expect(type.Templates[0].TemplatePublic).toBe("MY_NOTIFICATION_TYPE") + expect(type.Templates[0].TemplateGrouped).toBe("MY_NOTIFICATION_TYPE") + }) + }) + + describe("READ event", () => { + test("Reading the matching book fires a notification", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(201)") + expect(captured.length).toBeGreaterThan(0) + expect(captured[0].NotificationTypeKey).toContain("MY_NOTIFICATION_TYPE") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Reading a non-matching book does NOT fire a notification", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(207)") + expect(captured).toHaveLength(0) + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification for matching READ has Priority LOW", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(201)") + expect(captured[0]?.Priority).toBe("LOW") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification for matching READ has Properties from entity data", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(201)") + const props = captured[0]?.Properties ?? [] + const titleProp = props.find(p => p.Key === "title") + expect(titleProp?.Value).toBe("Wuthering Heights") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + }) + + describe("CREATE event", () => { + test("Creating a book fires a notification (CREATE is in on list)", async () => { + if (usesRestService) return + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await catalogTest.run( + INSERT.into("CatalogTest.Books").entries({ + ID: 999, + title: "Wuthering Heights", + author_ID: 101, + stock: 1, + price: 9.99, + currency_code: "GBP" + }) + ) + expect(captured.length).toBeGreaterThan(0) + expect(captured[0].NotificationTypeKey).toContain("MY_NOTIFICATION_TYPE") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + await DELETE.from("CatalogTest.Books").where({ ID: 999 }) + } + }) + }) +}) From bfdca24a5e1f4cc2c5e929c3981d03c6aeb29ac7 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 18:25:27 +0200 Subject: [PATCH 16/24] fix: remove nonsensical notification condition --- tests/bookshop/srv/notifications.cds | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index 34d47e6..c5ac6db 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -39,7 +39,7 @@ extend service CatalogService with { service CatalogTest { @notifications : [{ type: 'MY_NOTIFICATION_TYPE', - on: ['READ', 'CREATE'], + on: ['READ'], recipients: ($self.createdBy), where: ($self.title = 'Wuthering Heights'), priority: #Low, From c77088df2a1954c5ace10bfa84d11996e33a240d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 27 Aug 2026 18:50:57 +0200 Subject: [PATCH 17/24] remove: redundant CREATE test --- tests/integration/entityNotification.test.js | 27 -------------------- 1 file changed, 27 deletions(-) diff --git a/tests/integration/entityNotification.test.js b/tests/integration/entityNotification.test.js index 5850aa8..a08f6da 100644 --- a/tests/integration/entityNotification.test.js +++ b/tests/integration/entityNotification.test.js @@ -84,31 +84,4 @@ describe("Entity @notifications", () => { } }) }) - - describe("CREATE event", () => { - test("Creating a book fires a notification (CREATE is in on list)", async () => { - if (usesRestService) return - const captured = [] - const handler = msg => captured.push(msg.data) - alert.before("*", handler) - - try { - await catalogTest.run( - INSERT.into("CatalogTest.Books").entries({ - ID: 999, - title: "Wuthering Heights", - author_ID: 101, - stock: 1, - price: 9.99, - currency_code: "GBP" - }) - ) - expect(captured.length).toBeGreaterThan(0) - expect(captured[0].NotificationTypeKey).toContain("MY_NOTIFICATION_TYPE") - } finally { - alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) - await DELETE.from("CatalogTest.Books").where({ ID: 999 }) - } - }) - }) }) From 8a1f9e609bc2facc60744e4a45ef502ca1fa103c Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 28 Aug 2026 13:40:38 +0200 Subject: [PATCH 18/24] fix: GH comments and parameter testing --- cds-plugin.js | 6 +- lib/compile.js | 4 +- lib/notificationTypes.js | 17 ++--- lib/utils.js | 22 +++++- tests/bookshop/srv/notifications.cds | 7 ++ tests/integration/entityNotification.test.js | 46 ++++++++++++- tests/unit/lib/entityUnitNotify.test.js | 71 +++++++++++++++++++- tests/unit/lib/notificationTypes.test.js | 12 ++++ 8 files changed, 169 insertions(+), 16 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index 1afa106..e8d6638 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -1,7 +1,7 @@ const cds = require("@sap/cds") if (!cds.env.requires?.notifications?.enabled) return -const { buildNotificationFromEvent, replaceRefsInExpr, buildNotificationFromEntity } = require("./lib/utils") +const { buildNotificationFromEvent, buildNotificationFromEntity, resolveWhereXpr } = require("./lib/utils") cds.build?.register?.("notifications", require("./lib/build")) cds.on("loaded", m => { @@ -40,7 +40,9 @@ cds.on("serving", service => { const notifications = await cds.connect.to("notifications") for (const n of matching) { if (n.where) { - const where = n.where.xpr.map(token => (token?.ref?.[0] === "$self" ? { ref: token.ref.slice(1) } : token)) + const rawXpr = resolveWhereXpr(n.where) + if (!rawXpr) continue + const where = rawXpr.map(token => (token?.ref?.[0] === "$self" ? { ref: token.ref.slice(1) } : token)) const keyParams = req.params?.[0] let checkWhere = where if (keyParams) { diff --git a/lib/compile.js b/lib/compile.js index 0c70514..ba115db 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -75,11 +75,11 @@ function notificationTypesFromModel(model) { types.push(type) } - for (const def of Object.values(model.definitions)) { + for (const def of model.definitions) { if (def.kind !== "entity") continue const notifications = def["@notifications"] if (!notifications?.length) continue - for (const entry of Object.values(notifications)) { + for (const entry of notifications) { if (!entry.type) continue if (types.some(t => t.NotificationTypeKey === entry.type)) continue types.push({ diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index 4760fc2..0a9ceae 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -68,6 +68,15 @@ async function getNotificationTypes() { } async function createNotificationType(notificationType) { + const hasTemplate = notificationType.Templates?.some( + t => t.TemplateSensitive || t.TemplatePublic || t.TemplateGrouped + ) + if (!hasTemplate) { + throw new Error( + `Notification type '${notificationType.NotificationTypeKey}' has no template titles. At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation.` + ) + } + const notificationDestination = await getNotificationDestination() const csrfHeaders = await buildHeadersForDestination(notificationDestination, { url: NOTIFICATION_TYPES_API_ENDPOINT @@ -77,14 +86,6 @@ async function createNotificationType(notificationType) { LOG.warn( `Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} was not found. Creating it...` ) - const hasTemplate = notificationType.Templates?.some( - t => t.TemplateSensitive || t.TemplatePublic || t.TemplateGrouped - ) - if (!hasTemplate) { - throw new Error( - `Notification type '${notificationType.NotificationTypeKey}' has no template titles. At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation.` - ) - } let response try { diff --git a/lib/utils.js b/lib/utils.js index fb4215a..d45dd2d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -350,17 +350,36 @@ function resolveRecipients(recipients, data) { return [String(recipients)] } +function resolveWhereXpr(where) { + if (!where) return null + return where.xpr ?? (Array.isArray(where) ? where : null) +} + +function resolveParamValue(value, data) { + if (value?.ref) { + const field = value.ref[0] === "$self" ? value.ref[1] : value.ref[0] + return data[field] + } + if (value?.["="]) { + const path = value["="] + const field = path.startsWith("$self.") ? path.slice("$self.".length) : path + return data[field] + } + return value?.val ?? resolveEnumValue(value) +} + async function buildNotificationFromEntity(hook, data = {}) { // hook = one entry from the @notifications array on the entity, e.g. { type, on, recipients, where, parameters, priority } const Recipients = resolveRecipients(hook.recipients, data).map(id => ({ [getRecipientKey(id)]: id })) + const singleData = Array.isArray(data) ? data[0] ?? {} : data // If hook.parameters is specified, use it directly; otherwise auto-map all entity data properties by name const Properties = hook.parameters ? Object.entries(hook.parameters).map(([key, value]) => ({ Key: key, Language: cds.env.i18n?.default_language ?? "en", - Value: String(data[value.ref[0]] ?? ""), + Value: String(resolveParamValue(value, singleData) ?? ""), Type: "String", IsSensitive: true })) @@ -483,6 +502,7 @@ module.exports = { buildNotificationFromEvent, buildNotificationFromEntity, resolveRecipients, + resolveWhereXpr, mapCdsTypeToANSType, replaceRefsInExpr, applyValueLengthConstraints, diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index c5ac6db..42b4a73 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -43,6 +43,13 @@ service CatalogTest { recipients: ($self.createdBy), where: ($self.title = 'Wuthering Heights'), priority: #Low, + }, { + type: 'MY_NOTIFICATION_TYPE', + on: ['READ'], + recipients: ($self.createdBy), + where: ($self.title = 'Jane Eyre'), + priority: #Low, + parameters: { bookTitle: $self.title, bookId: $self.ID } }] entity Books as projection on my.Books; diff --git a/tests/integration/entityNotification.test.js b/tests/integration/entityNotification.test.js index a08f6da..a53c79c 100644 --- a/tests/integration/entityNotification.test.js +++ b/tests/integration/entityNotification.test.js @@ -49,7 +49,7 @@ describe("Entity @notifications", () => { alert.before("*", handler) try { - await GET("/odata/v4/catalog-test/Books(207)") + await GET("/odata/v4/catalog-test/Books(251)") expect(captured).toHaveLength(0) } finally { alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) @@ -84,4 +84,48 @@ describe("Entity @notifications", () => { } }) }) + + describe("READ event with explicit parameters", () => { + test("Notification recipient falls back to RecipientId for non-UUID createdBy", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(207)") + expect(captured[0]?.Recipients).toContainEqual({ RecipientId: "anonymous" }) + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification Properties contains only the explicitly specified parameters", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(207)") + const props = captured[0]?.Properties ?? [] + expect(props.map(p => p.Key)).toEqual(["bookTitle", "bookId"]) + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification Properties values are resolved from entity data", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(207)") + const props = captured[0]?.Properties ?? [] + expect(props.find(p => p.Key === "bookTitle")?.Value).toBe("Jane Eyre") + expect(props.find(p => p.Key === "bookId")?.Value).toBe("207") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + }) }) diff --git a/tests/unit/lib/entityUnitNotify.test.js b/tests/unit/lib/entityUnitNotify.test.js index c9cb234..530fb59 100644 --- a/tests/unit/lib/entityUnitNotify.test.js +++ b/tests/unit/lib/entityUnitNotify.test.js @@ -1,11 +1,35 @@ const cds = require("@sap/cds") -const { buildNotificationFromEntity, resolveRecipients } = require("../../../lib/utils") +const { buildNotificationFromEntity, resolveRecipients, resolveWhereXpr } = require("../../../lib/utils") const { notificationTypesFromModel } = require("../../../lib/compile") function makeModel(defs) { - return { definitions: Object.values(defs) } + const definitions = { ...defs } + definitions[Symbol.iterator] = function* () { yield* Object.values(this) } + return { definitions } } +describe("resolveWhereXpr", () => { + test("Returns xpr array when where has xpr property", () => { + const where = { xpr: [{ ref: ["title"] }, "=", { val: "Wuthering Heights" }] } + expect(resolveWhereXpr(where)).toBe(where.xpr) + }) + + test("Returns where directly when it is a plain array", () => { + const where = [{ ref: ["title"] }, "=", { val: "Wuthering Heights" }] + expect(resolveWhereXpr(where)).toBe(where) + }) + + test("Returns null when where has no xpr and is not an array", () => { + expect(resolveWhereXpr({ someOtherShape: true })).toBeNull() + }) + + test("Returns null for null/undefined", () => { + expect(resolveWhereXpr(null)).toBeNull() + expect(resolveWhereXpr(undefined)).toBeNull() + }) +}) + + describe("resolveRecipients", () => { test("Returns empty array for null", () => { expect(resolveRecipients(null, {})).toEqual([]) @@ -115,6 +139,49 @@ describe("buildNotificationFromEntity", () => { expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) }) + test("hook.parameters strips $self prefix from refs", async () => { + const hook = { + ...baseHook, + parameters: { + bookTitle: { ref: ["$self", "title"] } + } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) + }) + + test("hook.parameters resolves CDS '=' path expression format ({ '=': '$self.title' })", async () => { + const hook = { + ...baseHook, + parameters: { + bookTitle: { "=": "$self.title" }, + bookId: { "=": "$self.ID" } + } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties.find(p => p.Key === "bookTitle")?.Value).toBe("Wuthering Heights") + expect(result.Properties.find(p => p.Key === "bookId")?.Value).toBe("201") + }) + + test("hook.parameters with array data uses first element instead of crashing", async () => { + const hook = { + ...baseHook, + parameters: { bookTitle: { ref: ["title"] } } + } + const data = [{ title: "Wuthering Heights", createdBy: "alice@example.com" }] + const result = await buildNotificationFromEntity(hook, data) + expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) + }) + + test("hook.parameters with a plain literal value (no ref) uses val instead of crashing", async () => { + const hook = { + ...baseHook, + parameters: { staticKey: { val: "hardcoded" } } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties[0]).toMatchObject({ Key: "staticKey", Value: "hardcoded" }) + }) + test("Defaults Priority to NEUTRAL when hook has no priority", async () => { const result = await buildNotificationFromEntity(baseHook, baseData) expect(result.Priority).toBe("NEUTRAL") diff --git a/tests/unit/lib/notificationTypes.test.js b/tests/unit/lib/notificationTypes.test.js index 0b69b1d..a430e16 100644 --- a/tests/unit/lib/notificationTypes.test.js +++ b/tests/unit/lib/notificationTypes.test.js @@ -345,6 +345,18 @@ describe("Managing of Notification Types", () => { }) describe("Creating Types", () => { + test("Throws before any I/O when template has no title fields", async () => { + const emptyType = { + NotificationTypeKey: "EmptyType", + NotificationTypeVersion: "1", + Templates: [{ Language: "en", TemplateLanguage: "mustache" }] + } + await expect(notificationTypes.createNotificationType(emptyType)).rejects.toThrow( + "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided" + ) + expect(httpClient.executeHttpRequest).not.toHaveBeenCalled() + }) + test("Create Default and all new types when none exist in Work Zone", () => { httpClient.executeHttpRequest.mockReturnValue(emptyResponseBody) From 7bd2645eb8ed28790ebe5adbcb24fe11b795e62d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 28 Aug 2026 13:41:26 +0200 Subject: [PATCH 19/24] prettier --- lib/utils.js | 2 +- tests/unit/lib/entityUnitNotify.test.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/utils.js b/lib/utils.js index d45dd2d..cf6eef7 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -373,7 +373,7 @@ async function buildNotificationFromEntity(hook, data = {}) { const Recipients = resolveRecipients(hook.recipients, data).map(id => ({ [getRecipientKey(id)]: id })) - const singleData = Array.isArray(data) ? data[0] ?? {} : data + const singleData = Array.isArray(data) ? (data[0] ?? {}) : data // If hook.parameters is specified, use it directly; otherwise auto-map all entity data properties by name const Properties = hook.parameters ? Object.entries(hook.parameters).map(([key, value]) => ({ diff --git a/tests/unit/lib/entityUnitNotify.test.js b/tests/unit/lib/entityUnitNotify.test.js index 530fb59..3eee082 100644 --- a/tests/unit/lib/entityUnitNotify.test.js +++ b/tests/unit/lib/entityUnitNotify.test.js @@ -4,7 +4,9 @@ const { notificationTypesFromModel } = require("../../../lib/compile") function makeModel(defs) { const definitions = { ...defs } - definitions[Symbol.iterator] = function* () { yield* Object.values(this) } + definitions[Symbol.iterator] = function* () { + yield* Object.values(this) + } return { definitions } } @@ -29,7 +31,6 @@ describe("resolveWhereXpr", () => { }) }) - describe("resolveRecipients", () => { test("Returns empty array for null", () => { expect(resolveRecipients(null, {})).toEqual([]) From efa506e6248b200e727fd06b26d6e21eee94eacd Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 28 Aug 2026 13:53:16 +0200 Subject: [PATCH 20/24] remove: bad test --- tests/integration/entityNotification.test.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/integration/entityNotification.test.js b/tests/integration/entityNotification.test.js index a53c79c..dd56ac7 100644 --- a/tests/integration/entityNotification.test.js +++ b/tests/integration/entityNotification.test.js @@ -1,17 +1,13 @@ const cds = require("@sap/cds") const { join } = require("path") -const usesRestService = ["hybrid", "production"].includes(process.env.CDS_ENV) - const { GET } = cds.test(join(__dirname, "../bookshop")) describe("Entity @notifications", () => { let alert - let catalogTest beforeAll(async () => { alert = await cds.connect.to("notifications") - catalogTest = await cds.connect.to("CatalogTest") }) describe("Startup", () => { @@ -86,19 +82,6 @@ describe("Entity @notifications", () => { }) describe("READ event with explicit parameters", () => { - test("Notification recipient falls back to RecipientId for non-UUID createdBy", async () => { - const captured = [] - const handler = msg => captured.push(msg.data) - alert.before("*", handler) - - try { - await GET("/odata/v4/catalog-test/Books(207)") - expect(captured[0]?.Recipients).toContainEqual({ RecipientId: "anonymous" }) - } finally { - alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) - } - }) - test("Notification Properties contains only the explicitly specified parameters", async () => { const captured = [] const handler = msg => captured.push(msg.data) From 4dd3c94d712a944c2b332cdc14d0eacdcb9cfc57 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 28 Aug 2026 14:04:38 +0200 Subject: [PATCH 21/24] fix: array handling --- lib/utils.js | 2 +- tests/unit/lib/entityUnitNotify.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/utils.js b/lib/utils.js index cf6eef7..32eaae3 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -383,7 +383,7 @@ async function buildNotificationFromEntity(hook, data = {}) { Type: "String", IsSensitive: true })) - : Object.entries(data) + : Object.entries(singleData) .filter(([, value]) => String(value ?? "").length <= MAX_PROPERTY_VALUE_LENGTH) .map(([key, value]) => ({ Key: key, diff --git a/tests/unit/lib/entityUnitNotify.test.js b/tests/unit/lib/entityUnitNotify.test.js index 3eee082..62de19b 100644 --- a/tests/unit/lib/entityUnitNotify.test.js +++ b/tests/unit/lib/entityUnitNotify.test.js @@ -128,6 +128,15 @@ describe("buildNotificationFromEntity", () => { expect(titleProp.Value).toBe("") }) + test("Auto-maps entity fields when data is an array (no hook.parameters)", async () => { + const data = [{ ID: "201", title: "Wuthering Heights", createdBy: "alice@example.com" }] + const result = await buildNotificationFromEntity(baseHook, data) + const keys = result.Properties.map(p => p.Key) + expect(keys).not.toContain("0") + expect(keys).toContain("title") + expect(result.Properties.find(p => p.Key === "title")?.Value).toBe("Wuthering Heights") + }) + test("Uses explicit hook.parameters when provided, mapped by ref", async () => { const hook = { ...baseHook, From bb4b3cd72ba584a8632be3928229093583135f12 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 28 Aug 2026 14:14:48 +0200 Subject: [PATCH 22/24] fix: GH suggestions --- lib/compile.js | 7 ++++++- tests/integration/bookshop.test.js | 1 - tests/unit/lib/entityUnitNotify.test.js | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/compile.js b/lib/compile.js index ba115db..d88b6c7 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -82,6 +82,7 @@ function notificationTypesFromModel(model) { for (const entry of notifications) { if (!entry.type) continue if (types.some(t => t.NotificationTypeKey === entry.type)) continue + const defaultChannels = cds.env.requires?.notifications?.channels ?? ["workzone"] types.push({ NotificationTypeKey: entry.type, NotificationTypeVersion: "1", @@ -93,7 +94,11 @@ function notificationTypesFromModel(model) { TemplatePublic: entry.type, TemplateGrouped: entry.type } - ] + ], + DeliveryChannels: defaultChannels.map(ch => { + const channelType = CHANNEL_MAP[ch.toLowerCase()] ?? ch.toUpperCase() + return { Type: channelType, Enabled: true, DefaultPreference: true, EditablePreference: true } + }) }) } } diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 67badf2..459a28f 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -257,7 +257,6 @@ describe("Notifications Integration", () => { }) test("Throws clear error when deploying a notification type with empty templates", async () => { - if (!usesRestService) return const emptyType = { NotificationTypeKey: "EmptyType", NotificationTypeVersion: "1", diff --git a/tests/unit/lib/entityUnitNotify.test.js b/tests/unit/lib/entityUnitNotify.test.js index 62de19b..ac538c2 100644 --- a/tests/unit/lib/entityUnitNotify.test.js +++ b/tests/unit/lib/entityUnitNotify.test.js @@ -262,6 +262,21 @@ describe("notificationTypesFromModel — entity @notifications", () => { expect(type.Templates[0].TemplateGrouped).toBe("MY_TYPE") }) + test("Includes default DeliveryChannels on entity-derived types", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ type: "MY_TYPE", on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + const type = types.find(t => t.NotificationTypeKey === "MY_TYPE") + expect(type.DeliveryChannels).toBeDefined() + expect(type.DeliveryChannels.length).toBeGreaterThan(0) + expect(type.DeliveryChannels[0]).toMatchObject({ Type: "WEB", Enabled: true }) + }) + test("Skips @notifications entries that have no type field", () => { const model = makeModel({ MyEntity: { From 4042147bc059cd85fecba787b2e61f0cabbb07e5 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 1 Sep 2026 09:24:40 -0600 Subject: [PATCH 23/24] fix: readme issue --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ff3be4..6562a20 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,8 @@ event BookOrdered { ... } } ``` +> **Note:** `Enabled`, `DefaultPreference`, and `EditablePreference` are required ANS fields. Use the values as shown above without changing them. + ## Send Notifications There are two patterns for sending notifications. @@ -627,7 +629,7 @@ alert.notify({ ActorDisplayText: "ActorName", ActorImageURL: "https://some-url", NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", - TargetParameters: [{ Key: "string", Value: "string" }] + TargetParameters: [{ Key: "string", Value: "string" }] //tell Work Zone which record to open when the user clicks on the notification }) ``` From 347535363c99ca6acfaaba4366676220c65125ab Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 1 Sep 2026 12:06:54 -0600 Subject: [PATCH 24/24] add: note that normal annotation is supported --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 6562a20..56a9713 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,9 @@ Any event with at least one `@notification` annotation (the bare `@notification` > [!Important] > The event must be contained within a service either by defining it directly inside a `service` or by using `extend service` / `using` to include it in an existing one. +> [!Note] +> Annotations can also be placed in a separate file using the standard CDS `annotate` directive. + **Common annotations:** ```cds