Skip to content
Open
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -346,6 +349,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.
Expand Down Expand Up @@ -627,7 +632,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
})
```

Expand Down
36 changes: 35 additions & 1 deletion cds-plugin.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const cds = require("@sap/cds")
if (!cds.env.requires?.notifications?.enabled) return

const { buildNotificationFromEvent } = require("./lib/utils")
const { buildNotificationFromEvent, buildNotificationFromEntity, resolveWhereXpr } = require("./lib/utils")
cds.build?.register?.("notifications", require("./lib/build"))

cds.on("loaded", m => {
Expand Down Expand Up @@ -32,6 +32,40 @@ 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 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) {
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 entities = Array.isArray(results) ? results : [results]
for (const entity of entities) {
const notification = await buildNotificationFromEntity(n, entity)
try {
await notifications.notify(notification)
Comment thread
eric-pSAP marked this conversation as resolved.
} catch (err) {
const LOG = cds.log("notifications")
LOG._error && LOG.error("Failed to send notification for entity", n.type, err)
}
}
}
})
})

cds.once("served", async () => {
Expand Down
28 changes: 28 additions & 0 deletions lib/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,34 @@ function notificationTypesFromModel(model) {
types.push(type)
}

for (const def of model.definitions) {
if (def.kind !== "entity") continue
const notifications = def["@notifications"]
if (!notifications?.length) continue
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",
Templates: [
{
Language: defaultTexts,
TemplateLanguage: "mustache",
TemplateSensitive: entry.type,
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 }
})
})
Comment thread
eric-pSAP marked this conversation as resolved.
}
}

return types
}

Expand Down
10 changes: 10 additions & 0 deletions lib/notificationTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -329,5 +338,6 @@ async function processNotificationTypes(notificationTypesJSON) {

module.exports = {
createNotificationTypesMap,
createNotificationType,
processNotificationTypes
}
85 changes: 85 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,88 @@ 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)]
}

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(resolveParamValue(value, singleData) ?? ""),
Type: "String",
IsSensitive: true
}))
Comment thread
eric-pSAP marked this conversation as resolved.
: Object.entries(singleData)
.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
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,
Comment thread
eric-pSAP marked this conversation as resolved.
NotificationTypeVersion: "1",
Priority,
Properties,
Recipients
}

return notification
}

function applyValueLengthConstraints(notification) {
if (!notification) return notification

Expand Down Expand Up @@ -418,6 +500,9 @@ module.exports = {
getNotificationTypesKeyWithPrefix,
buildNotification,
buildNotificationFromEvent,
buildNotificationFromEntity,
resolveRecipients,
resolveWhereXpr,
mapCdsTypeToANSType,
replaceRefsInExpr,
applyValueLengthConstraints,
Expand Down
2 changes: 1 addition & 1 deletion tests/bookshop/db/data/sap.capire.bookshop-Authors.csv
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 20 additions & 0 deletions tests/bookshop/srv/notifications.cds
Original file line number Diff line number Diff line change
@@ -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}'
Expand Down Expand Up @@ -34,3 +35,22 @@ extend service CatalogService with {
recipients : array of String;
}
}

service CatalogTest {
@notifications : [{
type: 'MY_NOTIFICATION_TYPE',
on: ['READ'],
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;

}
16 changes: 14 additions & 2 deletions tests/integration/bookshop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const cds = require("@sap/cds")
const { join } = require("path")
const { messages } = require("../../lib/utils")
const { notificationTypesFromModel } = require("../../lib/compile")
const { createNotificationType } = require("../../lib/notificationTypes")

const usesRestService = ["hybrid", "production"].includes(process.env.CDS_ENV)

Expand Down Expand Up @@ -254,6 +255,17 @@ 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 () => {
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."
)
})
})

test("Batch of typed notifications logs each one to console", async () => {
Expand Down Expand Up @@ -334,7 +346,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
Expand All @@ -353,7 +365,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")
})
Expand Down
Loading