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)
const notificationTypes = [
...notificationTypesFromModel(model),
...( 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 } }
}
}

Comment on lines 19 to 40
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: An error thrown by notificationTypesFromModel (e.g. the new 128-character validation) or by cds.load will result in an unhandled promise rejection inside the async callback — there is no try/catch. This can crash the process or silently swallow startup failures depending on the Node.js version, making the problem very hard to diagnose.

Consider wrapping the body in a try/catch and logging via cds.log('notifications').


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

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),
...(typesPath ? readFile(typesPath) : [])
]

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

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

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

for (const def of Object.values(cds.reflect(model).definitions)) {
if (def.kind !== 'event') continue
if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue

const eventName = def.name.split('.').pop()
const violations = Object.keys(def.elements ?? {}).filter(name => name.length > 128)
if (violations.length) {
throw new Error(
`Event '${eventName}' has elements exceeding the maximum key length of 128 characters: ${violations.map(n => `'${n}'`).join(', ')}`
)
}

const tmpl = { Language: 'en', TemplateLanguage: 'mustache' }
if (def['@description']) tmpl.Description = resolveI18n(def['@description'])
if (def['@notification.template.title']) tmpl.TemplateSensitive = resolveI18n(def['@notification.template.title'])
if (def['@notification.template.publicTitle']) tmpl.TemplatePublic = resolveI18n(def['@notification.template.publicTitle'])
if (def['@notification.template.subtitle']) tmpl.Subtitle = resolveI18n(def['@notification.template.subtitle'])
if (def['@notification.template.groupedTitle']) tmpl.TemplateGrouped = resolveI18n(def['@notification.template.groupedTitle'])
if (def['@notification.template.email.subject']) tmpl.EmailSubject = resolveI18n(def['@notification.template.email.subject'])
if (def['@notification.template.email.html']) tmpl.EmailHtml = resolveI18n(def['@notification.template.email.html'])

const type = {
NotificationTypeKey: eventName,
NotificationTypeVersion: '1',
Templates: [tmpl],
}

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 may return a non-string, causing a TypeError on .toUpperCase()

resolveEnum returns val unchanged when it is not an object (e.g. a number), or when the object has neither = nor # (in which case the object itself is returned). In both cases calling .toUpperCase() on the result will throw TypeError: resolveEnum(...).toUpperCase is not a function at runtime.

Consider guarding with a String() coercion, or throwing a descriptive error when the resolved channel value is not a string.

Suggested change
const entry = { Type: resolveEnum(ch.channel).toUpperCase() }
const resolved = resolveEnum(ch.channel)
if (typeof resolved !== 'string') throw new Error(
`Event '${eventName}': deliveryChannel.channel resolved to a non-string value: ${JSON.stringify(resolved)}`
)
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 resolveI18n(value) {
if (typeof value !== 'string') return value
const match = value.match(/^\{i18n>([^}]+)\}$/)
if (!match) return value
return cds.i18n?.labels?.at(match[1], 'en') ?? value
}

module.exports = { notificationTypesFromModel }
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
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;
}
81 changes: 51 additions & 30 deletions tests/integration/bookshop.test.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,83 @@
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")
const { notificationTypesFromModel } = require("../../lib/compile")

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")
})

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}}")
})

expect(log.output).toContain("bookshop/BookOrdered");
expect(log.output).not.toContain("is not in the notification types file");
});
});
test("Throw when a notification event has an element name exceeding 128 characters", async () => {
const longName = 'a'.repeat(129)
const model = cds.parse.cdl(`@notification event OversizedEvent { ${longName}: String; }`)
expect(() => notificationTypesFromModel(model)).toThrow(
"Event 'OversizedEvent' has elements exceeding the maximum key length of 128 characters"
)
})
})
Loading
Loading