diff --git a/cds-plugin.js b/cds-plugin.js index 17f43a1..bd35034 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -1,4 +1,7 @@ const cds = require("@sap/cds/lib"); +const path = require('path'); +const { preprocessTypes } = require("./lib/buildNotificationTypes"); +const { existsSync } = require("fs"); if (cds.cli.command === "build") { // register build plugin @@ -10,11 +13,24 @@ if (cds.cli.command === "build") { else cds.once("served", async () => { const { validateNotificationTypes, readFile } = require("./lib/utils"); - const { createNotificationTypesMap } = require("./lib/notificationTypes"); + const { createNotificationTypesMap } = require("./lib/deployer/notificationTypes"); const production = cds.env.profiles?.includes("production"); // read notification types - const notificationTypes = readFile(cds.env.requires?.notifications?.types); + const notificationTypes = require(path.join(cds.root, cds.env.requires?.notifications?.types)); + + if (existsSync(cds.env.requires.notifications?.build?.before)) { + const handler = require(cds.env.requires.notifications?.build?.before); + await handler(notificationTypes); + } + + preprocessTypes(notificationTypes); + + if (existsSync(cds.env.requires.notifications?.build?.after)) { + const handler = require(path.join(cds.root, cds.env.requires.notifications?.build?.after)); + await handler(notificationTypes); + } + if (validateNotificationTypes(notificationTypes)) { if (!production) { const notificationTypesMap = createNotificationTypesMap(notificationTypes, true); diff --git a/index.cds b/index.cds new file mode 100644 index 0000000..17768df --- /dev/null +++ b/index.cds @@ -0,0 +1 @@ +using from './lib/notifications'; \ No newline at end of file diff --git a/lib/build.js b/lib/build.js index 7b3c960..b8e3426 100644 --- a/lib/build.js +++ b/lib/build.js @@ -1,10 +1,13 @@ -const cds = require('@sap/cds') +const cds = require('@sap/cds'); +const { readFile, getPrefix } = require('./utils'); +const { preprocessTypes } = require('./buildNotificationTypes'); +const { readFileSync } = require('fs'); const { BuildPlugin } = cds.build -const { copy, exists, path } = cds.utils +const { copy, exists, path, write } = cds.utils module.exports = class NotificationsBuildPlugin extends BuildPlugin { - + static taskDefaults = { src: cds.env.folders.srv } static hasTask() { const notificationTypesFile = cds.env.requires?.notifications?.types; return notificationTypesFile === undefined ? false : exists(notificationTypesFile); @@ -12,8 +15,33 @@ module.exports = class NotificationsBuildPlugin extends BuildPlugin { async build() { if (exists(cds.env.requires.notifications?.types)) { + const notificationTypes = require(path.join(cds.root, cds.env.requires.notifications.types)); + + if (exists(cds.env.requires.notifications?.build?.before)) { + const handler = require(cds.env.requires.notifications?.build?.before); + await handler(notificationTypes); + } + + preprocessTypes(notificationTypes); + + if (exists(cds.env.requires.notifications?.build?.after)) { + const handler = require(path.join(cds.root, cds.env.requires.notifications?.build?.after)); + await handler(notificationTypes); + } + const fileName = path.basename(cds.env.requires.notifications.types); - await copy(cds.env.requires.notifications.types).to(path.join(this.task.dest, fileName)); + await this.write(JSON.stringify(notificationTypes)).to(path.join(this.task.dest, fileName)); + + await this.write(JSON.stringify(notificationTypes)).to(path.join(this.task.dest, '../notifications/notification-types.json')); + + if (exists(path.join(this.task.src, '../node_modules/@cap-js/notifications/lib/deployer'))) { + await this.copy(path.join(this.task.src, '../node_modules/@cap-js/notifications/lib/deployer')).to(path.join(this.task.dest, '../notifications')) + } + const config = { + prefix: getPrefix(), + destination: cds.env.requires.notifications?.destination ?? "SAP_Notifications" + } + await this.write(JSON.stringify(config)).to(path.join(this.task.dest, '../notifications/config.json')) } } } \ No newline at end of file diff --git a/lib/buildNotificationTypes.js b/lib/buildNotificationTypes.js new file mode 100644 index 0000000..97f87ec --- /dev/null +++ b/lib/buildNotificationTypes.js @@ -0,0 +1,103 @@ +const { supportedANSLanguages } = require("./utils"); +const cds = require("@sap/cds"); +const path = require("path"); +const {TextBundle} = require('@sap/textbundle'); + +function buildNotificationType(_) { + const notificationType = { + NotificationTypeKey: _.NotificationTypeKey, + NotificationTypeVersion: _.NotificationTypeVersion, + Templates: _.Templates?.map(t => ({ + Language: t.Language, + TemplateSensitive: t.TemplateSensitive, + TemplatePublic: t.TemplatePublic, + TemplateGrouped: t.TemplateGrouped, + Subtitle: t.Subtitle, + Description: t.Description, + TemplateLanguage: t.TemplateLanguage, + EmailSubject: t.EmailSubject, + EmailHtml: t.EmailHtml, + EmailText: t.EmailText + })), + Actions: _.Actions?.map(a => ({ + ActionId: a.ActionId, + ActionText: a.ActionText, + GroupActionText: a.GroupActionText, + })), + DeliveryChannels: _.DeliveryChannels?.map(d => ({ + Type: d.Type, + Enabled: d.Enabled, + DefaultPreference: d.DefaultPreference, + EditablePreference: d.EditablePreference + })) + } + return JSON.parse(JSON.stringify(notificationType)); +} + +const preprocessTypes = function (types) { + if (!cds.env.requires.notifications) cds.env.requires.notifications = {} + if (!cds.env.requires.notifications.defaults) cds.env.requires.notifications.defaults = {} + + for (let i = 0; i < types.length; i++) { + const notificationType = types[i] + if (notificationType.ID) { + notificationType.NotificationTypeKey = notificationType.ID; + } + if (!notificationType.NotificationTypeVersion) notificationType.NotificationTypeVersion = "1" + if (!notificationType.Templates) { //-> Languages not manually specified + notificationType.Templates = []; + for (const language in supportedANSLanguages) { + const textBundle = new TextBundle(path.join(cds.root, (cds.env.i18n?.folders[0] ?? '_i18n') + '/notifications' ), language); + const newTemplate = { + Language: language, + TemplateLanguage: 'Mustache' + } + const getVal = (property) => textBundle.getText(notificationType[property]?.code ?? notificationType[property], (notificationType[property]?.args ?? []).map(a => `{{${a}}}`)) + if (notificationType.TemplateSensitive) newTemplate.TemplateSensitive = getVal('TemplateSensitive'); + if (notificationType.TemplatePublic) newTemplate.TemplatePublic = getVal('TemplatePublic'); + if (notificationType.TemplateGrouped) newTemplate.TemplateGrouped = getVal('TemplateGrouped'); + if (notificationType.Subtitle) newTemplate.Subtitle = getVal('Subtitle'); + if (notificationType.Description) newTemplate.Description = getVal('Description'); + if (notificationType.EmailSubject) newTemplate.EmailSubject = getVal('EmailSubject'); + if (notificationType.EmailHtml) newTemplate.EmailHtml = getVal('EmailHtml'); + if (notificationType.EmailText) newTemplate.EmailText = getVal('EmailText'); + notificationType.Templates.push(newTemplate) + } + } + + if (notificationType.DeliveryChannels && !Array.isArray(notificationType.DeliveryChannels)) { + const deliveryChannels = [] + for (const option in notificationType.DeliveryChannels) { + deliveryChannels.push({ + Type: option.toUpperCase(), + Enabled: !!notificationType.DeliveryChannels[option], + DefaultPreference: !!notificationType.DeliveryChannels[option], + EditablePreference: !!notificationType.DeliveryChannels[option] + }) + } + notificationType.DeliveryChannels = deliveryChannels; + } + + // + if (!cds.env.requires.notifications.defaults[notificationType.NotificationTypeKey]) + cds.env.requires.notifications.defaults[notificationType.NotificationTypeKey] = {} + if (!cds.env.requires.notifications.defaults[notificationType.NotificationTypeKey][notificationType.NotificationTypeVersion]) { + cds.env.requires.notifications.defaults[notificationType.NotificationTypeKey][notificationType.NotificationTypeVersion] = { + priority: notificationType.Priority ?? 'Neutral', + navigation: notificationType.Navigation ? { + semanticObject : notificationType.SemanticObject ?? notificationType.semanticObject, + semanticAction : notificationType.SemanticAction ?? notificationType.semanticAction, + } : null, + minTimeBetweenNotifications: notificationType.MinTimeBetweenNotifications ?? 0, + properties : notificationType.Properties ?? {}, + targetParameters : notificationType.TargetParameters ? new Set(...notificationType.TargetParameters) : new Set() + } + } + + types[i] = buildNotificationType(notificationType) + } +} + +module.exports = { + preprocessTypes +} diff --git a/lib/content-deployment.js b/lib/content-deployment.js deleted file mode 100644 index 522506c..0000000 --- a/lib/content-deployment.js +++ /dev/null @@ -1,22 +0,0 @@ -const cds = require("@sap/cds"); -const { validateNotificationTypes, readFile } = require("./utils"); -const { processNotificationTypes } = require("./notificationTypes"); -const { setGlobalLogLevel } = require("@sap-cloud-sdk/util"); - -async function deployNotificationTypes() { - setGlobalLogLevel("error"); - - // read notification types - const filePath = cds.env.requires?.notifications?.types ?? ''; - const notificationTypes = readFile(filePath); - - if (validateNotificationTypes(notificationTypes)) { - await processNotificationTypes(notificationTypes); - } -} - -deployNotificationTypes(); - -module.exports = { - deployNotificationTypes -} diff --git a/lib/deployer/content-deployment.js b/lib/deployer/content-deployment.js new file mode 100644 index 0000000..d7b6edf --- /dev/null +++ b/lib/deployer/content-deployment.js @@ -0,0 +1,17 @@ +const { readFileSync } = require('fs'); +const { processNotificationTypes } = require("./notificationTypes"); +const { setGlobalLogLevel } = require("@sap-cloud-sdk/util"); + +async function deployNotificationTypes() { + setGlobalLogLevel("error"); + + // read notification types + const notificationTypes = JSON.parse(readFileSync('./notification-types.json')); + await processNotificationTypes(notificationTypes); +} + +deployNotificationTypes(); + +module.exports = { + deployNotificationTypes +} diff --git a/lib/notificationTypes.js b/lib/deployer/notificationTypes.js similarity index 96% rename from lib/notificationTypes.js rename to lib/deployer/notificationTypes.js index 2b00b18..ecc2060 100644 --- a/lib/notificationTypes.js +++ b/lib/deployer/notificationTypes.js @@ -1,9 +1,9 @@ const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); +const { createLogger } = require("@sap-cloud-sdk/util"); +const LOG = createLogger('notifications'); const { getNotificationDestination, getPrefix, getNotificationTypesKeyWithPrefix } = require("./utils"); const NOTIFICATION_TYPES_API_ENDPOINT = "v2/NotificationType.svc"; -const cds = require("@sap/cds"); -const LOG = cds.log('notifications'); const defaultTemplate = { NotificationTypeKey: "Default", @@ -52,6 +52,9 @@ function createNotificationTypesMap(notificationTypesJSON, isLocal = false) { types[notificationTypeKeyWithPrefix] = {}; } + //Process smart types + //-> Simpler channels + Easier localisation of templates + types[notificationTypeKeyWithPrefix][notificationType.NotificationTypeVersion] = notificationType; }); @@ -73,7 +76,7 @@ async function createNotificationType(notificationType) { url: NOTIFICATION_TYPES_API_ENDPOINT }); - LOG._warn && LOG.warn( + LOG.warn( `Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} was not found. Creating it...` ); @@ -92,7 +95,7 @@ async function updateNotificationType(id, notificationType) { url: NOTIFICATION_TYPES_API_ENDPOINT }); - LOG._info && LOG.info( + LOG.info( `Detected change in notification type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion}. Updating it...` ); @@ -111,7 +114,7 @@ async function deleteNotificationType(notificationType) { url: NOTIFICATION_TYPES_API_ENDPOINT }); - LOG._info && LOG.info( + LOG.info( `Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} not present in the types file. Deleting it...` ); @@ -257,7 +260,7 @@ async function processNotificationTypes(notificationTypesJSON) { } if (!existingType.NotificationTypeKey.startsWith(`${prefix}/`)) { - LOG._info && LOG.info(`Skipping Notification Type of other application: ${existingType.NotificationTypeKey}.`); + LOG.info(`Skipping Notification Type of other application: ${existingType.NotificationTypeKey}.`); continue; } @@ -276,7 +279,7 @@ async function processNotificationTypes(notificationTypesJSON) { if (!isNotificationTypeEqual(existingType, newType)) { await updateNotificationType(existingType.NotificationTypeId, notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion]); } else { - LOG._info && LOG.info( + LOG.info( `Notification Type of key ${existingType.NotificationTypeKey} and version ${existingType.NotificationTypeVersion} unchanged.` ); } diff --git a/lib/deployer/package.json b/lib/deployer/package.json new file mode 100644 index 0000000..3fd4500 --- /dev/null +++ b/lib/deployer/package.json @@ -0,0 +1,12 @@ +{ + "name": "@cap-js/notifications-deployer", + "dependencies": { + "@sap-cloud-sdk/connectivity": "^3.13.0", + "@sap-cloud-sdk/http-client": "^3.13.0", + "@sap-cloud-sdk/util": "^3.13.0" + }, + "scripts": { + "start": "node ./content-deployment.js" + } + } + \ No newline at end of file diff --git a/lib/deployer/utils.js b/lib/deployer/utils.js new file mode 100644 index 0000000..ca379e5 --- /dev/null +++ b/lib/deployer/utils.js @@ -0,0 +1,55 @@ +const { getDestination } = require("@sap-cloud-sdk/connectivity"); +const {existsSync} = require('fs') +const messages = { + DESTINATION_NOT_FOUND: "Failed to get destination: ", +}; + +let prefix // be filled in below... +function getPrefixCdsEnv() { + 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 getConfig() { + if (existsSync('./config.json')) { + return JSON.parse(require('./config.json')); + } else { + return { + destination: cds.env.requires.notifications?.destination ?? "SAP_Notifications", + prefix: getPrefixCdsEnv() + } + } +} + +async function getNotificationDestination() { + const config = getConfig(); + const destinationName = config.destination ?? "SAP_Notifications"; + const notificationDestination = await getDestination({ destinationName, useCache: true }); + if (!notificationDestination) { + // TODO: What to do if destination isn't found?? + throw new Error(messages.DESTINATION_NOT_FOUND + destinationName); + } + return notificationDestination; +} + +function getPrefix() { + const config = getConfig(); + return config.prefix; +} + +function getNotificationTypesKeyWithPrefix(notificationTypeKey) { + const prefix = getPrefix(); + return `${prefix}/${notificationTypeKey}`; +} + +module.exports = { + getNotificationDestination, + getPrefix, + getNotificationTypesKeyWithPrefix, +}; diff --git a/lib/notifications.cds b/lib/notifications.cds new file mode 100644 index 0000000..37ee13e --- /dev/null +++ b/lib/notifications.cds @@ -0,0 +1,45 @@ +using { + cuid, + managed, + User, + sap.common.CodeList as CodeList, +} from '@sap/cds/common'; + +namespace sap.cds.common; + +@PersonalData : { + DataSubjectRole : 'User', + EntitySemantics : 'Other' +} +entity Notifications : cuid, managed { + notificationID: UUID; //This is the ID the Notification also has in the notifications service + notificationTypeKey : String; + recipient : User; + properties : Composition of many Properties on properties.notification = $self; + targetParameters : Composition of many TargetParameters on targetParameters.notification = $self; +} + +entity TargetParameters : cuid { + notificationID : UUID; + notification : Association to one Notifications on notification.notificationID = notificationID; + value : String(250); + name : String(250); +} + +entity Properties : cuid { + notificationID : UUID; + notification : Association to one Notifications on notification.notificationID = notificationID; + value : String(250); + name : String(250); + type : String(250); + isSensitive : String(250); +} + +annotate Notifications with @PersonalData : { + DataSubjectRole : 'User', + EntitySemantics : 'Other' +} { + recipient @PersonalData.FieldSemantics : 'UserID'; + //Semantically wrong, just to ensure deletion works + createdAt @PersonalData.FieldSemantics : 'EndOfBusinessDate'; +} \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js index 8390937..cc6423b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,5 +1,6 @@ const { existsSync, readFileSync } = require('fs'); const { basename } = require('path'); +const path = require('path'); const cds = require("@sap/cds"); const LOG = cds.log('notifications'); const { getDestination } = require("@sap-cloud-sdk/connectivity"); @@ -20,6 +21,10 @@ const messages = { EMPTY_OBJECT_FOR_NOTIFY: "Empty object is passed a single parameter to notify function.", NO_OBJECT_FOR_NOTIFY: "An object must be passed to notify function." }; +//See: https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/developing-cloud-foundry-applications-with-notifications +const supportedANSLanguages = {AF: 1, AR: 1, BG: 1, CA: 1, ZH: 1, ZF: 1, HR: 1, CS: 1, DA: 1, NL: 1, EN: 1, ET: 1, FI: 1, FR: 1, KM: 1, DE: 1, EL: 1, HE: 1, HU: 1, IS: 1, ID: 1, IT: 1, HI: 1, JA: 1, KO: 1, LV: 1, LT: 1, MS: 1, NO: 1, NB: 1, PL: 1, PT: 1, Z1: 1, RO: 1, RU: 1, SR: 1, SH: 1, SK: 1, VI: 1, SL: 1, ES: 1, SV: 1, TH: 1, TR: 1, UK: 1, IW: 1, IN: 1, ZH_HANS: 1, ZH_HANT: 1, ZH_CN: 1, ZH_TW: 1} + +const sanitizedLocale = () => cds.context?.locale && supportedANSLanguages[cds.context.locale] != undefined ? cds.context.locale : 'en' function validateNotificationTypes(notificationTypes) { for(let notificationType of notificationTypes){ @@ -67,7 +72,7 @@ function validateCustomNotifyParameters(type, recipients, properties, navigation return false; } - if (!Array.isArray(recipients) || recipients.length == 0) { + if (recipients.length == 0) { LOG._warn && LOG.warn(messages.RECIPIENTS_IS_NOT_ARRAY); return false; } @@ -97,7 +102,7 @@ function validateCustomNotifyParameters(type, recipients, properties, navigation function readFile(filePath) { - if (!existsSync(filePath)) { + if (!existsSync(path.join(cds.home, filePath))) { LOG._warn && LOG.warn(messages.TYPES_FILE_NOT_EXISTS); return []; } @@ -141,14 +146,14 @@ function buildDefaultNotification( const properties = [ { Key: "title", - Language: "en", + Language: sanitizedLocale(), Value: title, Type: "String", IsSensitive: false, }, { Key: "description", - Language: "en", + Language: sanitizedLocale(), Value: description, Type: "String", IsSensitive: false, @@ -160,32 +165,50 @@ function buildDefaultNotification( NotificationTypeVersion: "1", Priority: priority, Properties: properties, - Recipients: recipients.map((recipient) => ({ RecipientId: recipient })) + Recipients: recipients?.map(id => { + if (cds.env.features.use_global_user_uuid) + return { GlobalUserId: id } + return { RecipientId: id } + }) }; } function buildCustomNotification(_) { + const defaults = cds.env.requires.notifications.defaults[_.NotificationTypeKey ?? _.type][_.NotificationTypeVersion ?? "1"]; 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 - })), + Recipients: _.Recipients || _.recipients?.map(id => { + if (cds.env.features.use_global_user_uuid) + return { GlobalUserId: id } + return { RecipientId: id } + }), + Priority: _.Priority || _.priority || defaults.priority || "NEUTRAL", + Properties: _.Properties || Object.entries(_.data).map(([k,v]) => { + const propertyConfig = defaults.properties[k] + return { + Key:k, + Value: v ?? '', //Empty string if no value because null causes strange issues + Language: sanitizedLocale(), + Type: propertyConfig?.Type ?? typeof v, //Type property are OData types. In JS typeof would return string for dates causing wrong representations for dates + IsSensitive: propertyConfig?.IsSensitive ?? true //Default true as it means encryptedly stored in DB + } + }), // Low-level API properties OriginId: _.OriginId, NotificationTypeId: _.NotificationTypeId, NotificationTypeVersion: _.NotificationTypeVersion || "1", - NavigationTargetAction: _.NavigationTargetAction, - NavigationTargetObject: _.NavigationTargetObject, + NavigationTargetAction: _.NavigationTargetAction || defaults.navigation?.semanticObject, + NavigationTargetObject: _.NavigationTargetObject || defaults.navigation?.semanticAction, ProviderId: _.ProviderId, ActorId: _.ActorId, ActorDisplayText: _.ActorDisplayText, ActorImageURL: _.ActorImageURL, - TargetParameters: _.TargetParameters, + TargetParameters: _.TargetParameters || Object.entries(_.data).filter(([k,v]) => (defaults.targetParameters && defaults.targetParameters.has(k) || k === 'ID')).map(([k,v]) => ({ + Key:k, Value: v ?? '' + })), //If a data property is called ID consider it if no TargetParameters are defined, NotificationTypeTimestamp: _.NotificationTypeTimestamp, } return notification @@ -200,6 +223,8 @@ function buildNotification(notificationData) { } if (notificationData.type) { + if (notificationData.recipients && !Array.isArray(notificationData.recipients)) notificationData.recipients = [notificationData.recipients] + else if (notificationData.recipient) notificationData.recipients = [notificationData.recipient] if (!validateCustomNotifyParameters( notificationData.type, notificationData.recipients, @@ -236,6 +261,49 @@ function buildNotification(notificationData) { return JSON.parse(JSON.stringify(notification)); } +const calcTimeMs = timeout => { + const match = timeout.match(/^([0-9]+)(w|d|h|hrs|min)$/) + if (!match) return + + const [, val, t] = match + switch (t) { + case 'w': + return val * 1000 * 3600 * 24 * 7 + + case 'd': + return val * 1000 * 3600 * 24 + + case 'h': + case 'hrs': + return val * 1000 * 3600 + + case 'min': + return val * 1000 * 60 + + default: + return val + } +} + +const _config_to_ms = (config, _default) => { + const timeout = cds.env.requires?.notifications?.[config] + let timeout_ms + if (timeout === true) { + timeout_ms = calcTimeMs(_default) + } else if (typeof timeout === 'string') { + timeout_ms = calcTimeMs(timeout) + if (!timeout_ms) + throw new Error(` +${timeout} is an invalid value for \`cds.requires.notifications.${config}\`. +Please provide a value in format /^([0-9]+)(w|d|h|hrs|min)$/. +`) + } else { + timeout_ms = timeout + } + + return timeout_ms +} + module.exports = { messages, validateNotificationTypes, @@ -243,5 +311,8 @@ module.exports = { getNotificationDestination, getPrefix, getNotificationTypesKeyWithPrefix, - buildNotification + buildNotification, + supportedANSLanguages, + _config_to_ms, + calcTimeMs }; diff --git a/package-lock.json b/package-lock.json index 4c833f9..7eebf7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,26 @@ { "name": "@cap-js/notifications", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cap-js/notifications", - "version": "0.1.0", + "version": "0.2.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@sap-cloud-sdk/connectivity": "^3.13.0", "@sap-cloud-sdk/http-client": "^3.13.0", - "@sap-cloud-sdk/util": "^3.13.0" + "@sap-cloud-sdk/util": "^3.13.0", + "@sap/textbundle": "^5.2.0" }, "devDependencies": { "chai": "^4.3.10", "jest": "^29.7.0" }, "peerDependencies": { - "@sap/cds": "^7.3.1", - "@sap/cds-dk": "^7.3.1" + "@sap/cds": ">=7.3", + "@sap/cds-dk": ">=7.3" } }, "node_modules/@ampproject/remapping": { @@ -4464,6 +4465,14 @@ "node": ">=14" } }, + "node_modules/@sap/textbundle": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sap/textbundle/-/textbundle-5.2.0.tgz", + "integrity": "sha512-6ac9r0oqAqFVRnSkfFN+cwUL959QfV1KHczdtts6Pma3jD4YatdCpSCkGaprYIsGZVAm0GjZz1yNrjvMqrkr9Q==", + "engines": { + "node": "^18.0.0 || ^20.0.0" + } + }, "node_modules/@sap/xsenv": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@sap/xsenv/-/xsenv-4.2.0.tgz", @@ -11361,6 +11370,11 @@ "yaml": "^2.2.2" } }, + "@sap/textbundle": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sap/textbundle/-/textbundle-5.2.0.tgz", + "integrity": "sha512-6ac9r0oqAqFVRnSkfFN+cwUL959QfV1KHczdtts6Pma3jD4YatdCpSCkGaprYIsGZVAm0GjZz1yNrjvMqrkr9Q==" + }, "@sap/xsenv": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@sap/xsenv/-/xsenv-4.2.0.tgz", diff --git a/package.json b/package.json index c8118a3..49275cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cap-js/notifications", - "version": "0.2.0", + "version": "1.0.0", "description": "CDS plugin providing integration to the SAP BTP Alert Notification Service.", "repository": "cap-js/notifications", "author": "SAP SE (https://www.sap.com)", @@ -9,7 +9,8 @@ "main": "cds-plugin.js", "files": [ "lib", - "srv" + "srv", + "index.cds" ], "peerDependencies": { "@sap/cds": ">=7.3", @@ -18,11 +19,12 @@ "dependencies": { "@sap-cloud-sdk/connectivity": "^3.13.0", "@sap-cloud-sdk/http-client": "^3.13.0", - "@sap-cloud-sdk/util": "^3.13.0" + "@sap-cloud-sdk/util": "^3.13.0", + "@sap/textbundle": "^5.2.0" }, "devDependencies": { - "jest": "^29.7.0", - "chai": "^4.3.10" + "chai": "^4.3.10", + "jest": "^29.7.0" }, "scripts": { "lint": "npx eslint .", @@ -32,6 +34,10 @@ "cds": { "requires": { "destinations": true, + "alert-notification": { + "__note": "Service name for ANS. Config so it is added in MTX cases as a dependency", + "subscriptionDependency": "xsappname" + }, "notifications": { "[development]": { "kind": "notify-to-console" diff --git a/srv/notifyToConsole.js b/srv/notifyToConsole.js index f69320e..6d0fb91 100644 --- a/srv/notifyToConsole.js +++ b/srv/notifyToConsole.js @@ -3,35 +3,47 @@ const cds = require("@sap/cds"); const LOG = cds.log('notifications'); module.exports = class NotifyToConsole extends NotificationService { - async init() { - 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', - ) + postNotification(notificationData) { + const { NotificationTypeKey, NotificationTypeVersion } = notificationData + const types = cds.notifications.local.types // fetch notification types this way as there is no deployed service - const { NotificationTypeKey, NotificationTypeVersion } = notification - const types = cds.notifications.local.types // REVISIT: what is this? + if (!(NotificationTypeKey in types)) { + LOG._warn && LOG.warn( + `Notification Type ${NotificationTypeKey} is not in the notification types file` + ); + return; + } - if (!(NotificationTypeKey in types)) { - LOG._warn && LOG.warn( - `Notification Type ${NotificationTypeKey} is not in the notification types file` - ); - return; - } + if (!(NotificationTypeVersion in types[NotificationTypeKey])) { + LOG._warn && LOG.warn( + `Notification Type Version ${NotificationTypeVersion} for type ${NotificationTypeKey} is not in the notification types file` + ); + } - if (!(NotificationTypeVersion in types[NotificationTypeKey])) { - LOG._warn && LOG.warn( - `Notification Type Version ${NotificationTypeVersion} for type ${NotificationTypeKey} is not in the notification types file` - ); - } - }) - - return super.init() + console.log ( + '\n---------------------------------------------------------------\n' + + 'Notification:', notificationData.NotificationTypeKey, + notificationData, + '\n---------------------------------------------------------------\n', + ) + const notification = {Id: cds.utils.uuid()}; + return notificationData.Recipients.map(r => ({ + notificationID: notification.Id, + notificationTypeKey: notificationData.NotificationTypeKey, + recipient: r.RecipientId ?? r.GlobalUserId, + targetParameters: notificationData.TargetParameters.map(t => ({ + notificationID: notification.Id, + value: t.Value, + name: t.Key + })), + properties: notificationData.Properties.map(t => ({ + notificationID: notification.Id, + value: t.Value, + name: t.Key, + type: t.Type, + isSensitive: t.IsSensitive, + })) + })) } } diff --git a/srv/notifyToRest.js b/srv/notifyToRest.js index aa33316..1e01644 100644 --- a/srv/notifyToRest.js +++ b/srv/notifyToRest.js @@ -4,17 +4,18 @@ const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); const { getNotificationDestination } = require("../lib/utils"); const cds = require("@sap/cds"); +const { processNotificationTypes } = require("../lib/deployer/notificationTypes"); const LOG = cds.log('notifications'); const NOTIFICATIONS_API_ENDPOINT = "v2/Notification.svc"; module.exports = exports = class NotifyToRest extends NotificationService { - async init() { - this.on("*", req => this.postNotification(req.data)) - return super.init() - } - async postNotification(notificationData) { + if (!notificationData.Recipients || notificationData.Recipients.length === 0) { + LOG.warn(`Tried to send notification ${notificationData.NotificationTypeKey} without recipients!`); + return; + } + const notificationDestination = await getNotificationDestination(); const csrfHeaders = await buildHeadersForDestination(notificationDestination, { url: NOTIFICATIONS_API_ENDPOINT, @@ -24,12 +25,29 @@ module.exports = exports = class NotifyToRest extends NotificationService { LOG._info && LOG.info( `Sending notification of key: ${notificationData.NotificationTypeKey} and version: ${notificationData.NotificationTypeVersion}` ); - await executeHttpRequest(notificationDestination, { + const {data: {d: notification}} = await executeHttpRequest(notificationDestination, { url: `${NOTIFICATIONS_API_ENDPOINT}/Notifications`, method: "post", data: notificationData, headers: csrfHeaders, }); + return notificationData.Recipients.map(r => ({ + notificationID: notification.Id, + notificationTypeKey: notificationData.NotificationTypeKey, + recipient: r.RecipientId ?? r.GlobalUserId, + targetParameters: notificationData.TargetParameters.map(t => ({ + notificationID: notification.Id, + value: t.Value, + name: t.Key + })), + properties: notificationData.Properties.map(t => ({ + notificationID: notification.Id, + value: t.Value, + name: t.Key, + type: t.Type, + isSensitive: t.IsSensitive, + })) + })) } catch (err) { const message = err.response.data?.error?.message?.value ?? err.response.message; const error = new cds.error(message); diff --git a/srv/service.js b/srv/service.js index 41d34bf..af03545 100644 --- a/srv/service.js +++ b/srv/service.js @@ -1,9 +1,51 @@ -const { buildNotification, messages } = require("./../lib/utils") +const { buildNotification, messages, validateNotificationTypes, _config_to_ms, calcTimeMs, getNotificationTypesKeyWithPrefix, getPrefix } = require("./../lib/utils") +const { validateAndPreprocessHandler } = require("../lib/deployer/notificationTypes") const cds = require('@sap/cds') const LOG = cds.log('notifications'); +const DEL_TIMEOUT = { + get value() { + const timeout_ms = _config_to_ms('notifications_deletion_timeout', '30d') + Object.defineProperty(DEL_TIMEOUT, 'value', { value: timeout_ms }) + return timeout_ms + } +} + class NotificationService extends cds.Service { + async init() { + + this.on("*", async (req, next) => { + if (req.event === 'deleteNotifications') return next() + LOG._debug && LOG.debug('Handling notification event:', req.event); + let notificationsToCreate = []; + if (Array.isArray(req.data)) { + for (const notification of req.data) { + await this.checkForMinimumDays(notification); + const newNotifications = await this.postNotification(notification); + notificationsToCreate = notificationsToCreate.concat(newNotifications); + } + } else if (req.data) { + await this.checkForMinimumDays(req.data); + const newNotifications = await this.postNotification(req.data); + notificationsToCreate = notificationsToCreate.concat(newNotifications); + } + const {Notifications} = cds.entities('sap.cds.common'); + if (Notifications && notificationsToCreate.length > 0) { + await INSERT.into(Notifications).entries(notificationsToCreate) + } + }); + this.on('deleteNotifications', async msg => { + const {Notifications} = cds.entities('sap.cds.common'); + if (!Notifications) { + LOG.warn(`Notifications wont be deleted because the table does not exist.`) + return; + } + const expiryDate = new Date(Date.now() - DEL_TIMEOUT.value).toISOString() + await DELETE.from(Notifications).where({createdAt: {'<': expiryDate}}) + }); + return super.init() + } /** * Emits a notification. Method notify can be used alternatively. * @param {string} [event] - The notification type. @@ -18,11 +60,13 @@ class NotificationService extends cds.Service { 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) + if (Array.isArray(message)) { + for (let i = 0; i < message.length; i++) { + message[i] = processMessage(event, message[i]) + } + } else { + message = processMessage(event, message) + } return super.emit (event, message) } @@ -33,7 +77,66 @@ class NotificationService extends cds.Service { return this.emit (type,message) } + async postNotification(notificationData) { + //Implemented by sub classes + } + + async checkForMinimumDays(notification) { + const IDwithoutPrefix = notification.NotificationTypeKey.substring(getPrefix(notification.NotificationTypeKey).length+1, notification.NotificationTypeKey.length) + const defaults = + cds.env.requires.notifications.defaults[IDwithoutPrefix] + && cds.env.requires.notifications.defaults[IDwithoutPrefix][notification.NotificationTypeVersion ?? "1"]; + if (!defaults.minTimeBetweenNotifications) return; //Intended that it returns with 0 + const {Notifications} = cds.entities('sap.cds.common'); + if (!Notifications) { + LOG.warn(`Notification ${notification.NotificationTypeKey} has a minimum time between specified but Notifications table is not provided! Please includes @cap-js/notifications/index.cds in your cds model.`) + return; + } + const where = [ + {ref: ['recipient']}, + 'in', + {list: notification.Recipients.map(r => ({val: r.RecipientId ?? r.GlobalUserId}))}, + ]; + (notification.TargetParameters ?? []).forEach(target => { + if (target.Key !== 'IsActiveEntity') //Dont check because it may be 1 and not true on db + check makes no sense + where.push( + 'and', + 'exists', + { ref: [{ + id: 'targetParameters', + where: [{ ref: ['value'] }, '=', { val: target.Value }, 'and', { ref: ['name'] }, '=', { val: target.Key }] + }]}, + ); + }); + const minDistance = new Date(Date.now() - calcTimeMs(defaults.minTimeBetweenNotifications)).toISOString() + const receivedNotifications = await SELECT.from(Notifications).where([ + {ref: ['notificationTypeKey']}, + '=', + {val: notification.NotificationTypeKey}, + 'and', + {ref: ['createdAt']}, + '>', + { val: minDistance}, + 'and', + {xpr: where} + ]); + + notification.Recipients = notification.Recipients.filter(recipient => !receivedNotifications.some(notif => (recipient.RecipientId ?? recipient.GlobalUserId) === notif.recipient)); + if (notification.Recipients.length === 0) { + LOG.info(`${notification.NotificationTypeKey} notification without recipients after removing recipients who received the notification recently!`); + } + } +} + +const processMessage = (event, message) => { + // 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 message; } + module.exports = NotificationService // Without Generic Outbox only alert.notify() can be used, not alert.emit() diff --git a/test/lib/data/simple-notification-type.json b/test/lib/data/simple-notification-type.json new file mode 100644 index 0000000..379306f --- /dev/null +++ b/test/lib/data/simple-notification-type.json @@ -0,0 +1,35 @@ +{ + "ID": "SimpleType", + "TemplateSensitive": "I18NKEY", + "TemplatePublic": { + "code": "I18NKEY", + "args": ["property1", "property2"] + }, + "TemplateGrouped": "I18NKEY", + "Subtitle": "I18NKEY", + "Description": "I18NKEY", + "EmailSubject": "I18NKEY", + "EmailHtml": "I18NKEY", + "EmailText": "I18NKEY", + "DeliveryChannels": { + "Mail": true, + "Web": true + }, + "Priority": "Neutral,Medium,High,Low", + "Navigation": { + "SemanticObject": "", + "SemanticAction": "" + }, + "MinTimeBetweenNotifications": 0, + "Properties": { + "property1": { + "IsSensitive": true, + "Type": "String" + }, + "property2": { + "IsSensitive": true, + "Type": "Boolean" + } + }, + "TargetParameters": ["property1"] +} \ No newline at end of file