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)
Comment thread
eric-pSAP marked this conversation as resolved.
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 } }
}
}

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
Comment thread
eric-pSAP marked this conversation as resolved.
}

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'))
}
}
}
61 changes: 61 additions & 0 deletions lib/compile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 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: def.name.split('.').pop(),
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() }
Comment thread
eric-pSAP marked this conversation as resolved.
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;
}
71 changes: 41 additions & 30 deletions tests/integration/bookshop.test.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,73 @@
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}}")
})
})
Loading
Loading