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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions cds-plugin.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
const cds = require("@sap/cds/lib");
const cds = require("@sap/cds/lib")

cds.on("loaded", m => {
for (const def of Object.values(m.definitions)) {
if (def.kind !== 'event') continue
if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue
if (!def.elements) def.elements = {}
if (!def.elements.recipients) {
def.elements.recipients = { items: { type: 'cds.String' } }
}
}
})

if (cds.cli.command === "build") {
// register build plugin
cds.build?.register?.('notifications', require("./lib/build"));
cds.build?.register?.('notifications', require("./lib/build"))
}

else cds.once("served", async () => {
const { validateNotificationTypes, readFile } = require("./lib/utils");
const { createNotificationTypesMap } = require("./lib/notificationTypes");
const production = cds.env.profiles?.includes("production");
const { validateNotificationTypes, readFile } = require("./lib/utils")
const { createNotificationTypesMap } = require("./lib/notificationTypes")
const { notificationTypesFromModel } = require("./lib/compile")
const { path } = cds.utils
const production = cds.env.profiles?.includes("production")

const typesPath = cds.env.requires?.notifications?.types
const srvPath = path.join(cds.root, cds.env.folders.srv)
const model = await cds.load(srvPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: cds.load(srvPath) in the "served" handler loads the service model a second time, which is redundant and potentially inconsistent

By the time "served" fires, CDS has already fully loaded and linked the application model (accessible via cds.model). Calling cds.load(srvPath) again performs a fresh, separate load that bypasses any model extensions or modifications applied during the normal boot sequence (including the "loaded" hook registered earlier in this same file). This means the freshly-loaded model may not reflect the recipients elements that the "loaded" hook injects.

Instead, use the already-loaded and fully-resolved model:

Suggested change
const model = await cds.load(srvPath)
const model = cds.model

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

const notificationTypes = [
...notificationTypesFromModel(model, srvPath),
...( typesPath ? readFile(typesPath) : [] )
]

// read notification types
const notificationTypes = readFile(cds.env.requires?.notifications?.types);
if (validateNotificationTypes(notificationTypes)) {
if (!production) {
const notificationTypesMap = createNotificationTypesMap(notificationTypes, true);
cds.notifications = { local: { types: notificationTypesMap } };
const notificationTypesMap = createNotificationTypesMap(notificationTypes, true)
cds.notifications = { local: { types: notificationTypesMap } }
}
}

Expand Down
24 changes: 17 additions & 7 deletions lib/build.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
const cds = require('@sap/cds')

const { copy, exists, path } = cds.utils
const { path } = cds.utils

module.exports = class NotificationsBuildPlugin extends cds.build.Plugin {
static taskDefaults = { src: cds.env.folders.srv }

static hasTask() {
const notificationTypesFile = cds.env.requires?.notifications?.types;
return notificationTypesFile === undefined ? false : exists(notificationTypesFile);
return !!cds.env.requires?.notifications
}

async build() {
if (exists(cds.env.requires.notifications?.types)) {
const fileName = path.basename(cds.env.requires.notifications.types);
await copy(cds.env.requires.notifications.types).to(path.join(this.task.dest, fileName));
const model = await this.model()
if (!model) return

const { notificationTypesFromModel } = require('./compile')
const { readFile } = require('./utils')

const typesPath = cds.env.requires.notifications?.types
const types = [
...notificationTypesFromModel(model, this.task.src),
...(typesPath ? readFile(typesPath) : [])
]

if (types.length) {
await this.write(JSON.stringify(types, null, 2)).to(path.join(this.task.dest, 'notification-types.json'))
}
}
}
91 changes: 91 additions & 0 deletions lib/compile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const cds = require('@sap/cds')
const fs = require('fs')
const { join } = require('path')

function resolveEnum(val) {
if (val && typeof val === 'object' && '=' in val) return val['=']
return val
}

function notificationTypesFromModel(model, baseDir) {
if (!model) return []
const types = []

const i18nBundles = loadI18nBundles(baseDir ?? cds.root)
if (!i18nBundles) cds.log('notifications').warn('No i18n files found, falling back to English only')
const locales = i18nBundles ? Object.keys(i18nBundles) : ['en']

for (const def of Object.values(cds.reflect(model).definitions)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Logic Error: notificationTypesFromModel uses cds.reflect(model) on a plain object that may not be a real CDS linked model

In cds-plugin.js, the model is loaded with await cds.load(srvPath) which returns a linked/reflected model, so cds.reflect() there is fine. However, in unit tests (compile.test.js), the function is called with bare plain objects like { definitions: { ... } }. cds.reflect() on a plain object may throw or silently produce an empty/wrong definitions set, making real-world parity with the tests uncertain.

More importantly, the notificationTypesFromModel function called in cds-plugin.js at line 30 and in lib/build.js at line 21 receives model returned by cds.load() (already linked), but in compile.js line 18 it calls cds.reflect(model) which double-reflects it. cds.reflect() is idempotent for already-linked models, but when test helpers pass raw objects the .definitions lookup could return unexpected results.

Consider aligning the implementation to directly iterate model.definitions (as the "loaded" hook in cds-plugin.js already does at line 4), removing the cds.reflect() wrapper, since both call sites already provide a loaded/linked model:

for (const def of Object.values(model.definitions)) {
Suggested change
for (const def of Object.values(cds.reflect(model).definitions)) {
for (const def of Object.values(model.definitions)) {

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

if (def.kind !== 'event') continue
if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue

const templates = locales.map(locale => {
const texts = i18nBundles?.[locale] ?? {}
const t = { Language: locale, TemplateLanguage: 'mustache' }
if (def['@description']) t.Description = resolveI18n(def['@description'], texts)
if (def['@notification.template.title']) t.TemplateSensitive = resolveI18n(def['@notification.template.title'], texts)
if (def['@notification.template.publicTitle']) t.TemplatePublic = resolveI18n(def['@notification.template.publicTitle'], texts)
if (def['@notification.template.subtitle']) t.Subtitle = resolveI18n(def['@notification.template.subtitle'], texts)
if (def['@notification.template.groupedTitle']) t.TemplateGrouped = resolveI18n(def['@notification.template.groupedTitle'], texts)
if (def['@notification.template.email.subject']) t.EmailSubject = resolveI18n(def['@notification.template.email.subject'], texts)
if (def['@notification.template.email.html']) t.EmailHtml = resolveI18n(def['@notification.template.email.html'], texts)
return t
})

const type = {
NotificationTypeKey: def.name.split('.').pop(),
NotificationTypeVersion: '1',
Templates: templates,
}

if (def['@Common.SemanticObject']) type.NavigationTargetObject = def['@Common.SemanticObject']
if (def['@Common.SemanticObjectAction']) type.NavigationTargetAction = def['@Common.SemanticObjectAction']

if (def['@notification.deliveryChannels']?.length) {
type.DeliveryChannels = def['@notification.deliveryChannels'].map(ch => {
if (!ch.channel) return null
const entry = { Type: resolveEnum(ch.channel).toUpperCase() }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: resolveEnum(ch.channel).toUpperCase() will throw a TypeError if resolveEnum returns a non-string value

resolveEnum returns its input unchanged when it is not an object with "=". If ch.channel is null, a number, or an object without "=", calling .toUpperCase() on it will throw a TypeError at runtime.

The guard if (!ch.channel) return null on line 45 only catches falsy values. A truthy non-string (e.g. { channel: 42 } or an object missing "=") will still reach toUpperCase() and crash.

Consider adding a string check after resolving the enum value:

Suggested change
const entry = { Type: resolveEnum(ch.channel).toUpperCase() }
const resolved = resolveEnum(ch.channel)
if (typeof resolved !== 'string') return null
const entry = { Type: resolved.toUpperCase() }

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

if (ch.enabled !== undefined) entry.Enabled = ch.enabled
if (ch.defaultPreference !== undefined) entry.DefaultPreference = ch.defaultPreference
if (ch.editablePreference !== undefined) entry.EditablePreference = ch.editablePreference
return entry
}).filter(Boolean)
}

types.push(type)
}

return types
}

function parseProperties(content) {
const result = {}
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq >= 0) result[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim()
}
return result
}

function loadI18nBundles(baseDir) {
const i18nDir = join(baseDir, '_i18n')
if (!fs.existsSync(i18nDir)) return null
const bundles = {}
for (const file of fs.readdirSync(i18nDir)) {
const m = file.match(/^i18n(?:_(.+))?\.properties$/)
if (!m) continue
bundles[m[1] ?? 'en'] = parseProperties(fs.readFileSync(join(i18nDir, file), 'utf8'))
}
return Object.keys(bundles).length ? bundles : null
}

function resolveI18n(value, texts) {
if (typeof value !== 'string') return value
const match = value.match(/^\{i18n>([^}]+)\}$/)
if (!match) return value
return texts[match[1]] ?? value
}

module.exports = { notificationTypesFromModel }
9 changes: 5 additions & 4 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ function getRecipientKey() {
return recipientKey;
}

let prefix // be filled in below...
let prefix
function getPrefix() {
if (!prefix) {
prefix = cds.env.requires.notifications?.prefix
Expand All @@ -148,17 +148,18 @@ function buildDefaultNotification(
title,
description = ""
) {
const locale = cds.context?.locale ?? 'en'
const properties = [
{
Key: "title",
Language: "en",
Language: locale,
Value: title,
Type: "String",
IsSensitive: false,
},
{
Key: "description",
Language: "en",
Language: locale,
Value: description,
Type: "String",
IsSensitive: false,
Expand All @@ -182,7 +183,7 @@ function buildCustomNotification(_) {
Recipients: _.Recipients || _.recipients?.map(id => ({ [getRecipientKey()]: id })),
Priority: _.Priority || _.priority || "NEUTRAL",
Properties: _.Properties || Object.entries(_.data).map(([k,v]) => ({
Key:k, Value:v, Language: "en", Type: typeof v, // IsSensitive: false
Key:k, Value:v, Language: cds.context?.locale ?? 'en', Type: typeof v, // IsSensitive: false
})),

// Low-level API properties
Expand Down
5 changes: 5 additions & 0 deletions tests/bookshop/srv/_i18n/i18n.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BOOK_ORDERED_DESCRIPTION=Book Ordered
BOOK_ORDERED_TITLE=Book Ordered
BOOK_ORDERED_PUBLIC_TITLE=Book Ordered
BOOK_ORDERED_SUBTITLE={{buyer}} ordered {{title}}
BOOK_ORDERED_GROUPED_TITLE=Bookshop Updates
5 changes: 5 additions & 0 deletions tests/bookshop/srv/_i18n/i18n_de.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BOOK_ORDERED_DESCRIPTION=Buch bestellt
BOOK_ORDERED_TITLE=Buch bestellt
BOOK_ORDERED_PUBLIC_TITLE=Buch bestellt
BOOK_ORDERED_SUBTITLE={{buyer}} hat {{title}} bestellt
BOOK_ORDERED_GROUPED_TITLE=Buchhandlung-Aktualisierungen
9 changes: 3 additions & 6 deletions tests/bookshop/srv/notification-types.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
[
{
"NotificationTypeKey": "BookOrdered",
"NotificationTypeKey": "BookReturned",
"NotificationTypeVersion": "1",
"Templates": [
{
"Language": "en",
"TemplatePublic": "Book Ordered",
"TemplateSensitive": "Book '{{title}}' Ordered",
"TemplateGrouped": "Bookshop Updates",
"TemplateLanguage": "mustache",
"Subtitle": "{{buyer}} ordered {{title}}"
"TemplateSensitive": "Book '{{title}}' Returned",
"TemplateLanguage": "mustache"
}
]
}
Expand Down
14 changes: 14 additions & 0 deletions tests/bookshop/srv/notifications.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@description: '{i18n>BOOK_ORDERED_DESCRIPTION}'
@notification: {
template: {
title : '{i18n>BOOK_ORDERED_TITLE}',
publicTitle : '{i18n>BOOK_ORDERED_PUBLIC_TITLE}',
subtitle : '{i18n>BOOK_ORDERED_SUBTITLE}',
groupedTitle : '{i18n>BOOK_ORDERED_GROUPED_TITLE}',
}
}
event BookOrdered {
title : String;
buyer : String;
recipients: array of String;
}
95 changes: 65 additions & 30 deletions tests/integration/bookshop.test.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,97 @@
const cds = require("@sap/cds");
const { join } = cds.utils.path;
const { messages } = require("../../lib/utils");
const cds = require("@sap/cds")
const { join } = cds.utils.path
const { messages } = require("../../lib/utils")

cds.test(join(__dirname, "../bookshop"));
cds.test(join(__dirname, "../bookshop"))

describe("Notifications Integration", () => {
let log = cds.test.log()
let alert;
let alert

beforeAll(async () => {
alert = await cds.connect.to("notifications");
});
alert = await cds.connect.to("notifications")
})

test("Notifications service resolves to console implementation in development", async () => {
expect(alert.constructor.name).toBe("NotifyToConsole");
});
expect(alert.constructor.name).toBe("NotifyToConsole")
})

test("Notification types are loaded into cds.notifications on startup", () => {
expect(cds.notifications?.local?.types).toBeDefined();
expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered");
});
expect(cds.notifications?.local?.types).toBeDefined()
expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered")
})

test("Sending a notification with unknown type key gives a warning", async () => {
await alert.notify("UnknownType", {
recipients: ["reader@bookshop.com"],
data: { title: "test" }
});
})

expect(log.output).toContain("UnknownType is not in the notification types file");
});
expect(log.output).toContain("UnknownType is not in the notification types file")
})

test("Sending a default notification logs to console", async () => {
await alert.notify({
recipients: ["reader@bookshop.com"],
title: "New book arrived",
description: "A new book has been added to the catalogue"
});
})

expect(log.output).toContain("Notification:");
expect(log.output).toContain("NotificationTypeKey: 'Default'");
expect(log.output).toContain("RecipientId: 'reader@bookshop.com'");
expect(log.output).toContain("Value: 'New book arrived'");
});
expect(log.output).toContain("Notification:")
expect(log.output).toContain("NotificationTypeKey: 'Default'")
expect(log.output).toContain("RecipientId: 'reader@bookshop.com'")
expect(log.output).toContain("Value: 'New book arrived'")
})

test("Sending a notification with no arguments warns and does nothing", async () => {
await alert.notify();
await alert.notify()

expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY);
expect(log.output).not.toContain("Notification:");
});
expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY)
expect(log.output).not.toContain("Notification:")
})

test("Custom typed notification uses prefixed type key from types file", async () => {
await alert.notify("BookOrdered", {
recipients: ["reader@bookshop.com"],
data: { title: "Moby Dick", buyer: "reader@bookshop.com" }
});
})

expect(log.output).toContain("bookshop/BookOrdered");
expect(log.output).not.toContain("is not in the notification types file");
});
});
expect(log.output).toContain("bookshop/BookOrdered")
expect(log.output).not.toContain("is not in the notification types file")
})

test("Notification types from CDS and JSON are merged", () => {
expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered")
expect(cds.notifications.local.types).toHaveProperty("bookshop/BookReturned")
})

test("Notification type templates have resolved i18n values", () => {
const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"]
expect(type.Templates[0].TemplateSensitive).toBe("Book Ordered")
expect(type.Templates[0].Subtitle).toBe("{{buyer}} ordered {{title}}")
})

test("Notification type for BookOrdered has templates for all 44 ANS languages", () => {
const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"]
expect(type.Templates).toHaveLength(2)
expect(type.Templates.map(t => t.Language)).toContain("en")
expect(type.Templates.map(t => t.Language)).toContain("de")
})

test("German template for BookOrdered has German translation", () => {
const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"]
const de = type.Templates.find(t => t.Language === "de")
expect(de.TemplateSensitive).toBe("Buch bestellt")
expect(de.Subtitle).toBe("{{buyer}} hat {{title}} bestellt")
})

test("Notification type templates have resolved i18n values", () => {
const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"]
const en = type.Templates.find(t => t.Language === 'en')
const de = type.Templates.find(t => t.Language === 'de')
expect(en.TemplateSensitive).toBe("Book Ordered")
expect(en.Subtitle).toBe("{{buyer}} ordered {{title}}")
expect(de.TemplateSensitive).toBe("Buch bestellt")
expect(de.Subtitle).toBe("{{buyer}} hat {{title}} bestellt")
})
})
Loading
Loading