diff --git a/README.md b/README.md index cd7d188..81416f3 100644 --- a/README.md +++ b/README.md @@ -7,24 +7,14 @@ The `@cap-js/notifications` package is a [CDS plugin](https://cap.cloud.sap/docs ### Table of Contents - [Setup](#setup) -- [Usage](#usage) - - [Update Notification Configuration](#update-notification-configuration) - - [Notification Types Path](#notification-types-path) - - [Notification Type Prefix](#notification-type-prefix) - - [Add Notification Types](#add-notification-types) - - [Add code to send notifications](#add-code-to-send-notifications) - - [Simple Notification with title](#simple-notification-with-title) - - [Simple Notification with title and description](#simple-notification-with-title-and-description) - - [Custom Notifications](#custom-notifications) - - [With standard parameters](#with-standard-parameters) - - [Passing the whole notification object](#passing-the-whole-notification-object) - - [Sample Application with notifications](#sample-application-with-notifications) - - [In Local Environment](#in-local-environment) - - [In Production Environment](#in-production-environment) - - [Notification Destination](#notification-destination) - - [Integrate with SAP Build Work Zone](#integrate-with-sap-build-work-zone) +- [Send Notifications](#send-notifications) +- [Use Notification Types](#use-notification-types) +- [API Reference](#api-reference) +- [Test-drive Locally](#test-drive-locally) +- [Run in Production](#run-in-production) +- [Advanced Usage](#advanced-usage) - [Contributing](#contributing) - - [Code of Conduct](#code-of-conduct) +- [Code of Conduct](#code-of-conduct) - [Licensing](#licensing) ## Setup @@ -32,144 +22,147 @@ The `@cap-js/notifications` package is a [CDS plugin](https://cap.cloud.sap/docs To enable notifications, simply add this self-configuring plugin package to your project: ```sh - cds add notifications + npm add @cap-js/notifications ``` -cds add notifications - -## Usage - In this guide, we use the [Incidents Management reference sample app](https://github.com/cap-js/incidents-app) as the base, to publish notifications. -### Update Notification Configuration +## Send Notifications -`cds add notifications` will add default configurations for notifications in the `package.json` file. +With that you can use the NotificationService as any other CAP Service like so in you event handlers: -Default Notification config +```js +const alert = await cds.connect.to('notifications'); +``` -#### Notification Types Path +You can use the following signature to send the simple notification with title and description -When you run `cds add notifications`, it will add `notificationstype.json` file with template for a notification type in the project root folder. You can add the notification types in the `notificationtype.json` file for sending the custom notification types. +```js +alert.notify({ + recipients: [ ...supporters() ], + priority: "HIGH", + title: "New high priority incident is assigned to you!", + description: "Incident titled 'Engine overheating' created by 'customer X' with priority high is assigned to you!" +}); +``` -#### Notification Type Prefix +* **priority** - Priority of the notification, this argument is optional, it defaults to NEUTRAL +* **description** - Subtitle for the notification, this argument is optional -To make notification types unique to the application, prefix is added to the type key. By default, `application name` is added as the prefix. You can update the `prefix` if required. +## Use Notification Types -### Add Notification Types +### 1. Add notification types -If you want to send custom notifications in your application, you can add the notification types in the `notificationtype.json` file. +If you want to send custom notifications in your application, you can add the notification types in the `srv/notification-types.json` file. -Sample: If you want to send the notification when the new incident is reported, you can modify the `notificationtypes.json` as below: +Sample: If you want to send the notification when the incident is resolved, you can modify the `srv/notification-types.json` as below: -```jsonc -[ - { - "NotificationTypeKey": "IncidentReported", - "NotificationTypeVersion": "1", - "Templates": [ - { - "Language": "en", - "TemplatePublic": "Incident Reported", - "TemplateSensitive": "Incident '{{name}}' Reported", - "TemplateGrouped": "New Incidents", - "TemplateLanguage": "mustache", - "Subtitle": "Incident '{{name}}' reported by '{{customer}}'." - } - ] - } -] +```json + [ + { + "NotificationTypeKey": "IncidentResolved", + "NotificationTypeVersion": "1", + "Templates": [ + { + "Language": "en", + "TemplatePublic": "Incident Resolved", + "TemplateSensitive": "Incident '{{title}}' Resolved", + "TemplateGrouped": "Incident Status Update", + "TemplateLanguage": "mustache", + "Subtitle": "Incident from '{{customer}}' resolved by {{user}}." + } + ] + } + ] ``` -### Add code to send notifications +### 2. Use pre-defined types in your code like that: -In the handler files, connect to the notifications plugin by: - ```js -const alert = await cds.connect.to('notifications'); + await alert.notify ('IncidentResolved', { + recipients: [ customer.id ], + data: { + customer: customer.info, + title: incident.title, + user: cds.context.user.id, + } + }) ``` -#### Simple Notification with title -You can use the following signature to send the simple notification with title -```js -alert.notify({ - recipients: ["admin1@test.com","admin2@test.com"], - priority: "HIGH", - title: "New incident is reported!" -}); -``` -#### Simple Notification with title and description -You can use the following signature to send the simple notification with title and description -```js -alert.notify({ - recipients: ["supportuser1@test.com"], - priority: "HIGH", - title: "New high priority incident is assigned to you!", - description: "Incident titled 'Engine overheating' created by 'customer X' with priority high is assigned to you!" -}); -``` +## API Reference + +* **recipients** - List of the recipients, this argument is mandatory +* **type** - Notification type key, this argument is mandatory +* **priority** - Priority of the notification, this argument is optional, it defaults to NEUTRAL +* **data** - A key-value pair that is used to fill a placeholder of the notification type template, this argument is optional + +## Test-drive Locally +In local environment, when you publish notification, it is mocked to publish the nofication to the console. + +Notify to console + +## Run in Production + +#### Notification Destination + +As a pre-requisite to publish the notification, you need to have a [destination](https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/enabling-notifications-for-custom-apps-on-sap-btp-cloud-foundry#configure-the-destination-to-the-notifications-service) configured to publish the notification. In the `package.json` by default destination name `SAP_Notifications` is added, you can modify the destination name that you are configuring. + +#### Integrate with SAP Build Work Zone + +Once application is deployed and [integrated with SAP Build Work Zone](https://github.com/cap-js/calesi/tree/main/samples/notifications), you can see the notification under fiori notifications icon! + +Sample Application Demo + + + +## Advanced Usage + +### Custom Notification Types Path + +Notifications plugin configures `srv/notification-types.json` as default notification types file. If you are using different file, you can update the file path in `cds.env.requires.notifications.types` + +### Custom Notification Type Prefix + +To make notification types unique to the application, prefix is added to the type key. By default, `application name` is added as the prefix. You can update the `cds.env.requires.notifications.prefix` if required. + +### Low-level Notifications API -#### Custom Notifications You can use these two signature to send the custom notification with pre-defined notification types. -##### With standard parameters +#### With pre-defined parameters + +By using this approach you can send notifications with the predefined parameters - recipients, data, priority, type and other parameters listed in the [API documentation](https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/developing-cloud-foundry-applications-with-notifications) -By using this approach you can post a notification by providing different parts of the notification object grouped in related units ```js alert.notify({ - recipients: ["admin1@test.com","admin2@test.com"], - type: "IncidentCreated" + recipients: [...supporters()], + type: "IncidentResolved", priority: 'NEUTRAL', - properties: [ + data: { + customer: customer.info, + title: incident.title, + user: cds.context.user.id, + }, + OriginId: "Example Origin Id", + NotificationTypeVersion: "1", + ProviderId: "/SAMPLEPROVIDER", + ActorId: "BACKENDACTORID", + ActorDisplayText: "ActorName", + ActorImageURL: "https://some-url", + NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", + TargetParameters: [ { - Key: 'name', - IsSensitive: false, - Language: 'en', - Value: 'Engine overheating', - Type: 'String' - }, - { - Key: 'customer', - IsSensitive: false, - Language: 'en', - Value: 'John', - Type: 'String' + "Key": "string", + "Value": "string" } - ], - navigation: { - NavigationTargetAction: "displayInbox", - NavigationTargetObject: "WorkflowTask", - } - payload: { - Id: "01234567-89ab-cdef-0123-456789abcdef", - OriginId: "Example Origin Id", - NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", - NotificationTypeVersion: "1", - ProviderId: "/SAMPLEPROVIDER", - ActorId: "BACKENDACTORID", - ActorDisplayText: "ActorName", - ActorImageURL: "https://some-url", - NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", - TargetParameters: [ - { - "Key": "string", - "Value": "string" - } ] - } -}); + }); ``` -Possible parameters: -* **recipients** - List of the recipients, this argument is mandatory -* **type** - Notification type key, this argument is mandatory -* **priority** - Priority of the notification, this argument is optional, it defaults to NEUTRAL -* **properties** - A key-value pair that is used to fill a placeholder of the notification type template, this argument is optional -* **navigation** - All navigation related parameters, this argument is optional -* **payload** - The rest parameters that can be passed, this argument is optional +#### Passing the whole notification object -##### Passing the whole notification object +By using this approach you need to pass the whole notification object as described in the [API documentation](https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/developing-cloud-foundry-applications-with-notifications) -By using this approach you need to pass the whole notification object as described in the API documentation ```js alert.notify({ NotificationTypeKey: 'IncidentCreated', @@ -187,46 +180,22 @@ alert.notify({ Key: 'customer', IsSensitive: false, Language: 'en', - Value: 'John', + Value: 'Dave', Type: 'String' } ], - Recipients: ["admin1@test.com","admin2@test.com"] + Recipients: [{ RecipientId: "supportuser1@mycompany.com" },{ RecipientId: "supportuser2@mycompany.com" }] }); ``` -### Sample Application with notifications - -#### In Local Environment -In local environment, when you publish notification, it is mocked to publish the nofication to the console. - -Notify to console - -#### In Production Environment - -##### Notification Destination - -As a pre-requisite to publish the notification, you need to have a [destination](https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/enabling-notifications-for-custom-apps-on-sap-btp-cloud-foundry#configure-the-destination-to-the-notifications-service) configured to publish the notification. In the `package.json` by default destination name `SAP_Notification` is added, you can modify the destination name that you are configuring. - -##### Integrate with SAP Build Work Zone - -Once application is deployed and [integrated with SAP Build Work Zone](https://github.com/cap-js/calesi/tree/main/samples/notifications), you can see the notification under fiori notifications icon! - -Sample Application Demo - ## Contributing This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/change-tracking/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). - -### Code of Conduct +## Code of Conduct We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times. - ## Licensing Copyright 2023 SAP SE or an SAP affiliate company and contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/change-tracking). - - - diff --git a/_assets/cdsAddNotifications.gif b/_assets/cdsAddNotifications.gif deleted file mode 100644 index 9317c59..0000000 Binary files a/_assets/cdsAddNotifications.gif and /dev/null differ diff --git a/_assets/incidentsNotificationDemo.gif b/_assets/incidentsNotificationDemo.gif index dc06584..63a3755 100644 Binary files a/_assets/incidentsNotificationDemo.gif and b/_assets/incidentsNotificationDemo.gif differ diff --git a/_assets/notifyToConsole.png b/_assets/notifyToConsole.png index d00be0f..a00b0d7 100644 Binary files a/_assets/notifyToConsole.png and b/_assets/notifyToConsole.png differ diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index e7df758..aeac40a 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -1,6 +1,6 @@ const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); -const { getNotificationDestination, doesKeyExist, getPrefix, getNotificationTypesKeyWithPrefix } = require("./utils"); +const { getNotificationDestination, getPrefix, getNotificationTypesKeyWithPrefix } = require("./utils"); const _ = require("lodash"); const NOTIFICATION_TYPES_API_ENDPOINT = "v2/NotificationType.svc"; const cds = require("@sap/cds"); @@ -49,7 +49,7 @@ function createNotificationTypesMap(notificationTypesJSON, isLocal = false) { // update the notification type key with prefix notificationType.NotificationTypeKey = notificationTypeKeyWithPrefix; - if (!doesKeyExist(types, notificationTypeKeyWithPrefix)) { + if (!(notificationTypeKeyWithPrefix in types)) { types[notificationTypeKeyWithPrefix] = {}; } diff --git a/lib/notifications.js b/lib/notifications.js deleted file mode 100644 index 7c400aa..0000000 --- a/lib/notifications.js +++ /dev/null @@ -1,37 +0,0 @@ -const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); -const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); -const { getNotificationDestination } = require("./utils"); -const LOG = cds.log('notifications'); -const NOTIFICATIONS_API_ENDPOINT = "v2/Notification.svc"; - -async function postNotification(notificationData) { - const notificationDestination = await getNotificationDestination(); - const csrfHeaders = await buildHeadersForDestination(notificationDestination, { - url: NOTIFICATIONS_API_ENDPOINT, - }); - - try { - LOG._info && LOG.info( - `Sending notification of key: ${notificationData.NotificationTypeKey} and version: ${notificationData.NotificationTypeVersion}` - ); - await executeHttpRequest(notificationDestination, { - url: `${NOTIFICATIONS_API_ENDPOINT}/Notifications`, - method: "post", - data: notificationData, - headers: csrfHeaders, - }); - } catch (err) { - const message = err.response.data?.error?.message?.value ?? err.response.message; - const error = new cds.error(message); - - if (String(err.response?.status).match(/^4\d\d$/) && err.response?.status !== 429) { - error.unrecoverable = true; - } - - throw error; - } -} - -module.exports = { - postNotification -}; \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js index ae8b8b7..e4c161f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -95,9 +95,6 @@ function validateCustomNotifyParameters(type, recipients, properties, navigation return true; } -function doesKeyExist(obj, key) { - return typeof(key) === 'string' && typeof(obj) === 'object' && key in obj; -} function readFile(filePath) { if (!existsSync(filePath)) { @@ -118,8 +115,16 @@ async function getNotificationDestination() { return notificationDestination; } +let prefix // be filled in below... function getPrefix() { - return cds.env.requires.notifications?.prefix ?? basename(cds.root); + if (!prefix) { + prefix = cds.env.requires.notifications?.prefix + if (prefix === "$app-name") try { + prefix = require(cds.root + '/package.json').name + } catch { prefix = null } + if (!prefix) prefix = basename(cds.root) + } + return prefix } function getNotificationTypesKeyWithPrefix(notificationTypeKey) { @@ -159,71 +164,72 @@ function buildDefaultNotification( }; } -function buildCustomNotification(notificationData) { - return { - Id: notificationData["payload"] ? notificationData["payload"]["Id"] : undefined, - OriginId: notificationData["payload"] ? notificationData["payload"]["OriginId"] : undefined, - NotificationTypeId: notificationData["payload"] ? notificationData["payload"]["NotificationTypeId"] : undefined, - NotificationTypeKey: getNotificationTypesKeyWithPrefix(notificationData["type"]), - NotificationTypeVersion: notificationData["payload"] && notificationData["payload"]["NotificationTypeVersion"] ? notificationData["payload"]["NotificationTypeVersion"] : "1", - NavigationTargetAction: notificationData["navigation"] ? notificationData["navigation"]["NavigationTargetAction"] : undefined, - NavigationTargetObject: notificationData["navigation"] ? notificationData["navigation"]["NavigationTargetObject"] : undefined, - Priority: notificationData["priority"] ? notificationData["priority"] : "NEUTRAL", - ProviderId: notificationData["payload"] ? notificationData["payload"]["ProviderId"] : undefined, - ActorId: notificationData["payload"] ? notificationData["payload"]["ActorId"] : undefined, - ActorDisplayText: notificationData["payload"] ? notificationData["payload"]["ActorDisplayText"] : undefined, - ActorImageURL: notificationData["payload"] ? notificationData["payload"]["ActorImageURL"] : undefined, - NotificationTypeTimestamp: notificationData["payload"] ? notificationData["payload"]["NotificationTypeTimestamp"] : undefined, - Recipients: notificationData["recipients"].map((recipient) => ({ RecipientId: recipient })), - Properties: notificationData["properties"] ? notificationData["properties"] : undefined, - TargetParameters: notificationData["payload"] ? notificationData["payload"]["TargetParameters"] : undefined - }; +function buildCustomNotification(_) { + let notification = { + + // Properties with simple API variants + NotificationTypeKey: getNotificationTypesKeyWithPrefix(_.NotificationTypeKey || _.type), + Recipients: _.Recipients || _.recipients?.map(id => ({ RecipientId: id })), + Priority: _.Priority || _.priority || "NEUTRAL", + Properties: _.Properties || Object.entries(_.data).map(([k,v]) => ({ + Key:k, Value:v, Language: "en", Type: typeof v, // IsSensitive: false + })), + + // Low-level API properties + OriginId: _.OriginId, + NotificationTypeId: _.NotificationTypeId, + NotificationTypeVersion: _.NotificationTypeVersion || "1", + NavigationTargetAction: _.NavigationTargetAction, + NavigationTargetObject: _.NavigationTargetObject, + ProviderId: _.ProviderId, + ActorId: _.ActorId, + ActorDisplayText: _.ActorDisplayText, + ActorImageURL: _.ActorImageURL, + TargetParameters: _.TargetParameters, + NotificationTypeTimestamp: _.NotificationTypeTimestamp, + } + return notification } function buildNotification(notificationData) { let notification; - if(notificationData === undefined || notificationData === null) { - LOG._warn && LOG.warn(messages.NO_OBJECT_FOR_NOTIFY); - return; - } - if (Object.keys(notificationData).length === 0) { LOG._warn && LOG.warn(messages.EMPTY_OBJECT_FOR_NOTIFY); return; } - if (notificationData["type"]) { + if (notificationData.type) { if (!validateCustomNotifyParameters( - notificationData["type"], - notificationData["recipients"], - notificationData["properties"], - notificationData["navigation"], - notificationData["priority"], - notificationData["payload"]) + notificationData.type, + notificationData.recipients, + notificationData.properties, + notificationData.navigation, + notificationData.priority, + notificationData.payload) ) { return; } notification = buildCustomNotification(notificationData); - } else if (notificationData["NotificationTypeKey"]) { - notificationData["NotificationTypeKey"] = getNotificationTypesKeyWithPrefix(notificationData["NotificationTypeKey"]); + } else if (notificationData.NotificationTypeKey) { + notificationData.NotificationTypeKey = getNotificationTypesKeyWithPrefix(notificationData.NotificationTypeKey); notification = notificationData; } else { if (!validateDefaultNotifyParameters( - notificationData["recipients"], - notificationData["priority"], - notificationData["title"], - notificationData["description"]) + notificationData.recipients, + notificationData.priority, + notificationData.title, + notificationData.description) ) { return; } notification = buildDefaultNotification( - notificationData["recipients"], - notificationData["priority"], - notificationData["title"], - notificationData["description"] + notificationData.recipients, + notificationData.priority, + notificationData.title, + notificationData.description ); } @@ -234,7 +240,6 @@ module.exports = { messages, validateNotificationTypes, readFile, - doesKeyExist, getNotificationDestination, getPrefix, getNotificationTypesKeyWithPrefix, diff --git a/package.json b/package.json index 6de475c..f22a471 100644 --- a/package.json +++ b/package.json @@ -32,22 +32,25 @@ }, "cds": { "requires": { - "kinds": { - "notifications": { - "[development]": { - "kind": "notify-to-console" - }, - "[production]": { - "kind": "notify-to-rest" - } + "destinations": true, + "notifications": { + "[development]": { + "kind": "notify-to-console" + }, + "[production]": { + "destination": "SAP_Notifications", + "kind": "notify-to-rest" }, + "prefix": "$app-name", + "types": "srv/notification-types.json", + "outbox": true + }, + "kinds": { "notify-to-console": { - "impl": "@cap-js/notifications/srv/notifyToConsole", - "outbox": false + "impl": "@cap-js/notifications/srv/notifyToConsole" }, "notify-to-rest": { - "impl": "@cap-js/notifications/srv/notifyToRest", - "outbox": true + "impl": "@cap-js/notifications/srv/notifyToRest" } } } diff --git a/srv/notifyToConsole.js b/srv/notifyToConsole.js index ad04dbf..f69320e 100644 --- a/srv/notifyToConsole.js +++ b/srv/notifyToConsole.js @@ -1,34 +1,37 @@ const NotificationService = require('./service'); const cds = require("@sap/cds"); const LOG = cds.log('notifications'); -const { buildNotification, doesKeyExist } = require("./../lib/utils"); module.exports = class NotifyToConsole extends NotificationService { async init() { - // call NotificationService's init - await super.init(); - } - notify() { + this.on("*", req => { + LOG._debug && LOG.debug('Handling notification event:', req.event) + const notification = req.data; if (!notification) return + console.log ( + '\n---------------------------------------------------------------\n' + + 'Notification:', req.event, + notification, + '\n---------------------------------------------------------------\n', + ) - const notification = buildNotification(arguments[0]); + const { NotificationTypeKey, NotificationTypeVersion } = notification + const types = cds.notifications.local.types // REVISIT: what is this? - if (notification) { - LOG._info && LOG.info(`SAP Alert Notification Service notification: ${JSON.stringify(notification, null, 2)}`); - const existingTypes = cds.notifications.local.types; - - if (!doesKeyExist(existingTypes, notification["NotificationTypeKey"])) { + if (!(NotificationTypeKey in types)) { LOG._warn && LOG.warn( - `Notification Type ${notification["NotificationTypeKey"]} is not in the notification types file` + `Notification Type ${NotificationTypeKey} is not in the notification types file` ); return; } - if (!doesKeyExist(existingTypes[notification["NotificationTypeKey"]], notification["NotificationTypeVersion"])) { + if (!(NotificationTypeVersion in types[NotificationTypeKey])) { LOG._warn && LOG.warn( - `Notification Type Version ${notification["NotificationTypeVersion"]} for type ${notification["NotificationTypeKey"]} is not in the notification types file` + `Notification Type Version ${NotificationTypeVersion} for type ${NotificationTypeKey} is not in the notification types file` ); } - } + }) + + return super.init() } } diff --git a/srv/notifyToRest.js b/srv/notifyToRest.js index efd93d0..dcc5394 100644 --- a/srv/notifyToRest.js +++ b/srv/notifyToRest.js @@ -1,20 +1,43 @@ -const NotificationService = require("./service"); -const { buildNotification } = require("../lib/utils"); -const { postNotification } = require("../lib/notifications"); +const NotificationService = require("./service") + +const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); +const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); +const { getNotificationDestination } = require("../lib/utils"); +const LOG = cds.log('notifications'); +const NOTIFICATIONS_API_ENDPOINT = "v2/Notification.svc"; -module.exports = class NotifyToRest extends NotificationService { - async init() { - // call NotificationService's init - await super.init(); - this.on("postNotificationEvent", async (req) => await postNotification(req.data)); +module.exports = exports = class NotifyToRest extends NotificationService { + async init() { + this.on("*", req => this.postNotification(req.data)) + return super.init() } - async notify(notificationData) { - const notification = buildNotification(notificationData); + async postNotification(notificationData) { + const notificationDestination = await getNotificationDestination(); + const csrfHeaders = await buildHeadersForDestination(notificationDestination, { + url: NOTIFICATIONS_API_ENDPOINT, + }); + + try { + LOG._info && LOG.info( + `Sending notification of key: ${notificationData.NotificationTypeKey} and version: ${notificationData.NotificationTypeVersion}` + ); + await executeHttpRequest(notificationDestination, { + url: `${NOTIFICATIONS_API_ENDPOINT}/Notifications`, + method: "post", + data: notificationData, + headers: csrfHeaders, + }); + } catch (err) { + const message = err.response.data?.error?.message?.value ?? err.response.message; + const error = new cds.error(message); + + if (/^4\d\d$/.test(err.response?.status) && err.response?.status != 429) { + error.unrecoverable = true; + } - if (notification) { - await this.emit({ event: "postNotificationEvent", data: notification }); + throw error; } } -}; +} diff --git a/srv/service.js b/srv/service.js index cc2a833..41d34bf 100644 --- a/srv/service.js +++ b/srv/service.js @@ -1,14 +1,45 @@ -// REVISIT: cds.OutboxService or technique to avoid extending OutboxService -const OutboxService = require('@sap/cds/libx/_runtime/messaging/Outbox'); +const { buildNotification, messages } = require("./../lib/utils") +const cds = require('@sap/cds') +const LOG = cds.log('notifications'); -module.exports = class NotificationService extends OutboxService { - async init() { +class NotificationService extends cds.Service { - // call OutboxService's init - await super.init(); + /** + * Emits a notification. Method notify can be used alternatively. + * @param {string} [event] - The notification type. + * @param {object} message - The message object + */ + emit (event, message) { + if (!event) { + LOG._warn && LOG.warn(messages.NO_OBJECT_FOR_NOTIFY); + return; + } + // Outbox calls us with a req object, e.g. { event, data, headers } + if (event.event) return super.emit (event) + // First argument is optional for convenience + if (!message) [ message, event ] = [ event ] + // CAP events translate to notification types and vice versa + if (event) message.type = event + else event = message.type || message.NotificationTypeKey || 'Default' + // Prepare and emit the notification + message = buildNotification(message) + return super.emit (event, message) } - notify() { - // Abstract function + /** + * That's just a semantic alias for emit. + */ + notify (type, message) { + return this.emit (type,message) } + +} +module.exports = NotificationService + +// Without Generic Outbox only alert.notify() can be used, not alert.emit() +// Remove that when @sap/cds with Generic Outbox is released +if (!cds.outboxed) { + class OutboxedNotificationService extends require('@sap/cds/libx/_runtime/messaging/Outbox') {} + OutboxedNotificationService.prototype.notify = NotificationService.prototype.emit + module.exports = OutboxedNotificationService } diff --git a/test/lib/content-deployment.test.js b/test/lib/content-deployment.test.js index 559759e..fc000da 100644 --- a/test/lib/content-deployment.test.js +++ b/test/lib/content-deployment.test.js @@ -26,7 +26,6 @@ describe("contentDeployment", () => { console.log(setGlobalLogLevel.mock.calls); assert.expect(setGlobalLogLevel.mock.calls[0][0]).to.be.equal("error"); - assert.expect(readFile.mock.calls[0][0]).to.be.equal(''); assert.expect(validateNotificationTypes.mock.calls[0][0]).to.be.deep.equal([]); assert.expect(processNotificationTypes.mock.calls[0][0]).to.be.deep.equal([]); }); @@ -41,7 +40,6 @@ describe("contentDeployment", () => { console.log(setGlobalLogLevel.mock.calls); assert.expect(setGlobalLogLevel.mock.calls[0][0]).to.be.equal("error"); - assert.expect(readFile.mock.calls[0][0]).to.be.equal(''); assert.expect(validateNotificationTypes.mock.calls[0][0]).to.be.deep.equal([]); assert.expect(processNotificationTypes.mock.calls[0]).to.be.deep.equal(undefined); }); diff --git a/test/lib/notificationTypes.test.js b/test/lib/notificationTypes.test.js index cde5111..40eccef 100644 --- a/test/lib/notificationTypes.test.js +++ b/test/lib/notificationTypes.test.js @@ -18,6 +18,7 @@ describe("Managing of Notification Types", () => { httpClient.executeHttpRequest.mockReturnValue(emptyResponseBody); connectivity.buildHeadersForDestination.mockReturnValue({}); utils.getNotificationTypesKeyWithPrefix.mockImplementation((str) => testPrefix + "/" + str); + // REVISIT: Never test internal APIs -> blocks us from refactoring utils.getPrefix.mockReturnValue(testPrefix); notificationTypes.processNotificationTypes([copy(notificationTypeWithAllProperties), copy(notificationTypeWithoutVersion)]).then(() => { diff --git a/test/lib/notifications.test.js b/test/lib/notifications.test.js index cb5a3f5..55d7c9f 100644 --- a/test/lib/notifications.test.js +++ b/test/lib/notifications.test.js @@ -1,6 +1,6 @@ const { getNotificationDestination } = require("./../../lib/utils"); const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); -const { postNotification } = require("./../../lib/notifications"); +const NotifyToRest = require("./../../srv/notifyToRest"); const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); jest.mock("./../../lib/utils"); @@ -37,13 +37,14 @@ const expectedCustomNotification = { describe("Test post notification", () => { test("When passed whole notification object to postNotification", async () => { + const alert = new NotifyToRest const infoSpy = jest.spyOn(global.console, 'info'); getNotificationDestination.mockReturnValue(undefined); buildHeadersForDestination.mockReturnValue(undefined); executeHttpRequest.mockReturnValue(expectedCustomNotification); // call post notification - await postNotification(expectedCustomNotification) + await alert.postNotification(expectedCustomNotification) // check if console.info was called expect(infoSpy).toHaveBeenCalled(); @@ -55,6 +56,7 @@ describe("Test post notification", () => { }) test("When execute http request throws error with status code 500", async () => { + const alert = new NotifyToRest const error = new Error(); error.response = { message: "mocked error", @@ -70,7 +72,7 @@ describe("Test post notification", () => { // call post notification try { - await postNotification(expectedCustomNotification); + await alert.postNotification(expectedCustomNotification); } catch (err) { expect(err.unrecoverable).toBeFalsy(); } @@ -85,6 +87,7 @@ describe("Test post notification", () => { }) test("When execute http request throws error with status code 404", async () => { + const alert = new NotifyToRest const error = new Error(); error.response = { message: "mocked error", @@ -100,7 +103,7 @@ describe("Test post notification", () => { // call post notification try { - await postNotification(expectedCustomNotification); + await alert.postNotification(expectedCustomNotification); } catch (err) { expect(err.unrecoverable).toEqual(true); } @@ -115,6 +118,7 @@ describe("Test post notification", () => { }) test("When execute http request throws error with status code 429", async () => { + const alert = new NotifyToRest const error = new Error(); error.response = { message: "mocked error", @@ -130,7 +134,7 @@ describe("Test post notification", () => { // call post notification try { - await postNotification(expectedCustomNotification); + await alert.postNotification(expectedCustomNotification); } catch (err) { expect(err.unrecoverable).toBeFalsy(); } diff --git a/test/lib/utils.test.js b/test/lib/utils.test.js index 6ac4261..883a218 100644 --- a/test/lib/utils.test.js +++ b/test/lib/utils.test.js @@ -1,4 +1,4 @@ -const { buildNotification, validateNotificationTypes, doesKeyExist, readFile, getNotificationDestination } = require("../../lib/utils"); +const { buildNotification, validateNotificationTypes, readFile, getNotificationDestination } = require("../../lib/utils"); const { existsSync, readFileSync } = require("fs"); const { getDestination } = require("@sap-cloud-sdk/connectivity"); @@ -125,7 +125,7 @@ describe("Test utils", () => { buildNotification({ recipients: ["test.mail@mail.com"], type: "TestNotificationType", - properties: [ + Properties: [ { Key: "title", IsSensitive: false, @@ -165,7 +165,7 @@ describe("Test utils", () => { buildNotification({ recipients: ["test.mail@mail.com"], type: "TestNotificationType", - properties: [ + Properties: [ { Key: "title", IsSensitive: false, @@ -174,10 +174,8 @@ describe("Test utils", () => { Type: "String" } ], - navigation: { - NavigationTargetAction: "TestTargetAction", - NavigationTargetObject: "TestTargetObject" - } + NavigationTargetAction: "TestTargetAction", + NavigationTargetObject: "TestTargetObject", }) ).toMatchObject(expectedNotification); }); @@ -209,7 +207,7 @@ describe("Test utils", () => { buildNotification({ recipients: ["test.mail@mail.com"], type: "TestNotificationType", - properties: [ + Properties: [ { Key: "title", IsSensitive: false, @@ -218,10 +216,8 @@ describe("Test utils", () => { Type: "String" } ], - navigation: { - NavigationTargetAction: "TestTargetAction", - NavigationTargetObject: "TestTargetObject" - }, + NavigationTargetAction: "TestTargetAction", + NavigationTargetObject: "TestTargetObject", priority: "HIGH" }) ).toMatchObject(expectedNotification); @@ -229,7 +225,6 @@ describe("Test utils", () => { test("When recipients, type, properties, navigation, priority, payload are passed to buildNotification", () => { const expectedNotification = { - Id: "01234567-89ab-cdef-0123-456789abcdef", OriginId: "01234567-89ab-cdef-0123-456789abcdef", NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", NotificationTypeKey: "notifications/TestNotificationType", @@ -268,7 +263,7 @@ describe("Test utils", () => { buildNotification({ recipients: ["test.mail@mail.com"], type: "TestNotificationType", - properties: [ + Properties: [ { Key: "title", IsSensitive: false, @@ -277,27 +272,22 @@ describe("Test utils", () => { Type: "String" } ], - navigation: { - NavigationTargetAction: "TestTargetAction", - NavigationTargetObject: "TestTargetObject" - }, + NavigationTargetAction: "TestTargetAction", + NavigationTargetObject: "TestTargetObject", priority: "HIGH", - payload: { - Id: "01234567-89ab-cdef-0123-456789abcdef", - OriginId: "01234567-89ab-cdef-0123-456789abcdef", - NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", - ProviderId: "SAMPLEPROVIDER", - ActorId: "BACKENDACTORID", - ActorDisplayText: "ActorName", - ActorImageURL: "https://some-url", - NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", - TargetParameters: [ - { - Key: "string", - Value: "string" - } - ] - } + OriginId: "01234567-89ab-cdef-0123-456789abcdef", + NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", + ProviderId: "SAMPLEPROVIDER", + ActorId: "BACKENDACTORID", + ActorDisplayText: "ActorName", + ActorImageURL: "https://some-url", + NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", + TargetParameters: [ + { + Key: "string", + Value: "string" + } + ] }) ).toMatchObject(expectedNotification); }); @@ -341,7 +331,7 @@ describe("Test utils", () => { buildNotification({ recipients: ["test.mail@mail.com"], type: "TestNotificationType", - properties: [ + Properties: [ { Key: "title", IsSensitive: false, @@ -350,25 +340,21 @@ describe("Test utils", () => { Type: "String" } ], - navigation: { - NavigationTargetAction: "TestTargetAction", - NavigationTargetObject: "TestTargetObject" - }, + NavigationTargetAction: "TestTargetAction", + NavigationTargetObject: "TestTargetObject", priority: "HIGH", - payload: { - NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", - ProviderId: "SAMPLEPROVIDER", - ActorId: "BACKENDACTORID", - ActorDisplayText: "ActorName", - ActorImageURL: "https://some-url", - NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", - TargetParameters: [ - { - Key: "string", - Value: "string" - } - ] - } + NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", + ProviderId: "SAMPLEPROVIDER", + ActorId: "BACKENDACTORID", + ActorDisplayText: "ActorName", + ActorImageURL: "https://some-url", + NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", + TargetParameters: [ + { + Key: "string", + Value: "string" + } + ] }) ).toMatchObject(expectedNotification); }); @@ -553,22 +539,6 @@ describe("Test utils", () => { ).toBeFalsy(); }); - test("When invalid payload for custom notification is passed to buildNotification", () => { - expect( - buildNotification({ - recipients: ["test.mail@mail.com"], - type: "TestNotificationType", - priority: "NEUTRAL", - payload: "invalid" - }) - ).toBeFalsy(); - }); - - test("When no notification data for custom notification is passed to buildNotification", () => { - expect(buildNotification(undefined)).toBeFalsy(); - expect(buildNotification(null)).toBeFalsy(); - }); - test("Given invalid NTypes | When validateNotificationTypes is called | Then false is returned", () => { expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { blabla: "Test2" }])).toEqual(false); }); @@ -578,20 +548,6 @@ describe("Test utils", () => { expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { NotificationTypeKey: "Test2" }])).toEqual(true); }); - test("Given invalid inputs | When doesKeyExist is called | Then false is returned", () => { - expect(doesKeyExist({ test: "test1" }, {})).toEqual(false); - expect(doesKeyExist([{ test: "test1" }], "test")).toEqual(false); - }); - - test("Given that key does not exist | When doesKeyExist is called | Then false is returned", () => { - expect(doesKeyExist({ test: "test1" }, "doesnotexist")).toEqual(false); - expect(doesKeyExist({ test: "test1" }, "test1")).toEqual(false); - }); - - test("Given that key does exist | When doesKeyExist is called | Then true is returned", () => { - expect(doesKeyExist({ test: "test1" }, "test")).toEqual(true); - }); - test("Given that file does not exist | When readFile is called | Then empty array is returned", () => { existsSync.mockReturnValue(false); expect(readFile("test.json")).toMatchObject([]); diff --git a/test/srv/notifyToRest.test.js b/test/srv/notifyToRest.test.js index b2a0883..e2f1348 100644 --- a/test/srv/notifyToRest.test.js +++ b/test/srv/notifyToRest.test.js @@ -1,15 +1,11 @@ -const NotifyToRest = require("../../srv/notifyToRest"); const { messages, buildNotification } = require("../../lib/utils"); -const { postNotification } = require("../../lib/notifications"); - -jest.mock("./../../lib/notifications"); +const NotifyToRest = require("../../srv/notifyToRest"); describe("notify to rest", () => { it("when no object is passed", async () => { const notifyToRest = new NotifyToRest(); const warnSpy = jest.spyOn(global.console, "warn"); notifyToRest.notify(); - expect(warnSpy).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith("[notifications] -", messages.NO_OBJECT_FOR_NOTIFY); warnSpy.mockClear(); }); @@ -18,7 +14,6 @@ describe("notify to rest", () => { const notifyToRest = new NotifyToRest(); const warnSpy = jest.spyOn(global.console, "warn"); notifyToRest.notify({}); - expect(warnSpy).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith("[notifications] -", messages.EMPTY_OBJECT_FOR_NOTIFY); warnSpy.mockClear(); }); @@ -27,7 +22,6 @@ describe("notify to rest", () => { const notifyToRest = new NotifyToRest(); const warnSpy = jest.spyOn(global.console, "warn"); notifyToRest.notify({ dummy: true }); - expect(warnSpy).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith("[notifications] -", messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_DEFAULT_NOTIFICATION); warnSpy.mockClear(); }); @@ -36,7 +30,6 @@ describe("notify to rest", () => { const notifyToRest = new NotifyToRest(); const warnSpy = jest.spyOn(global.console, "warn"); notifyToRest.notify({ title: 1, recipients: ["abc@abc.com"] }); - expect(warnSpy).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith("[notifications] -", messages.TITLE_IS_NOT_STRING); warnSpy.mockClear(); }); @@ -45,7 +38,6 @@ describe("notify to rest", () => { const notifyToRest = new NotifyToRest(); const warnSpy = jest.spyOn(global.console, "warn"); notifyToRest.notify({ title: "abc", recipients: ["abc@abc.com"], priority: "abc" }); - expect(warnSpy).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith("[notifications] -", "Invalid priority abc. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH"); warnSpy.mockClear(); }); @@ -54,19 +46,16 @@ describe("notify to rest", () => { const notifyToRest = new NotifyToRest(); const warnSpy = jest.spyOn(global.console, "warn"); notifyToRest.notify({ title: "abc", recipients: ["abc@abc.com"], priority: "low", description: true }); - expect(warnSpy).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith("[notifications] -", messages.DESCRIPTION_IS_NOT_STRING); warnSpy.mockClear(); }); it(`When correct body is send | Then notification is posted`, async () => { - postNotification.mockImplementation(() => undefined); const body = { title: "abc", recipients: ["abc@abc.com"], priority: "low" }; - const notifyToRest = new NotifyToRest(); + let notification; notifyToRest.postNotification = n => notification = n await notifyToRest.init(); await notifyToRest.notify(body); - expect(postNotification).toHaveBeenCalled(); - expect(postNotification.mock.calls[0][0]).toMatchObject(buildNotification(body)); + expect(notification).toMatchObject(buildNotification(body)); }); });