diff --git a/lib/compile.js b/lib/compile.js index 426d809..aa8f8fa 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -1,4 +1,7 @@ +const { existsSync, readFileSync } = require('fs') +const { resolve, dirname } = require('path') const cds = require('@sap/cds') +const LOG = cds.log('notifications') function resolveEnum(val) { if (val && typeof val === 'object') { @@ -23,7 +26,7 @@ function notificationTypesFromModel(model) { if (def['@notification.template.subtitle']) tmpl.Subtitle = resolveI18n(def['@notification.template.subtitle']) if (def['@notification.template.groupedTitle']) tmpl.TemplateGrouped = resolveI18n(def['@notification.template.groupedTitle']) if (def['@notification.template.email.subject']) tmpl.EmailSubject = resolveI18n(def['@notification.template.email.subject']) - if (def['@notification.template.email.html']) tmpl.EmailHtml = resolveI18n(def['@notification.template.email.html']) + if (def['@notification.template.email.html']) tmpl.EmailHtml = resolveI18n(resolveHtmlFile(def['@notification.template.email.html'], def.$location)) const type = { NotificationTypeKey: def.name.split('.').pop(), @@ -45,6 +48,10 @@ function notificationTypesFromModel(model) { }).filter(Boolean) } + if (!type.DeliveryChannels && cds.env.requires?.notifications?.defaultEmailDelivery) { + type.DeliveryChannels = [{ Type: 'MAIL', Enabled: true, DefaultPreference: true, EditablePreference: true }] + } + types.push(type) } @@ -53,9 +60,22 @@ function notificationTypesFromModel(model) { function resolveI18n(value) { if (typeof value !== 'string') return value - const match = value.match(/^\{i18n>([^}]+)\}$/) - if (!match) return value - return cds.i18n?.labels?.at(match[1], 'en') ?? value + return value.replace(/\{i18n>([^}]+)\}/g, (match, key) => + cds.i18n?.labels?.at(key, 'en') ?? match + ) +} + +function resolveHtmlFile(value, location) { + if (typeof value !== 'string') return value + if (!value.startsWith('./') && !value.startsWith('../')) return value + + const cdsFile = resolve(cds.root, location.file) + const htmlPath = resolve(dirname(cdsFile), value) + if (!existsSync(htmlPath)) { + LOG._warn && LOG.warn(`HTML file not found: ${htmlPath}`) + return value + } + return readFileSync(htmlPath, 'utf8') } module.exports = { notificationTypesFromModel } diff --git a/tests/bookshop/srv/book-ordered-email.html b/tests/bookshop/srv/book-ordered-email.html new file mode 100644 index 0000000..a244cbf --- /dev/null +++ b/tests/bookshop/srv/book-ordered-email.html @@ -0,0 +1,2 @@ +
Hi {{buyer}}, your order for {{title}} has been placed.
diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index d2c8666..90fcdaa 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -8,7 +8,7 @@ service notificationService { groupedTitle : '{i18n>BOOK_ORDERED_GROUPED_TITLE}', email : { subject: 'Book Ordered: {{title}}', - html : 'Hi {{buyer}},
Your order for {{title}} has been placed.
', + html : './book-ordered-email.html', } }, deliveryChannels: [{ channel: 'MAIL', enabled: true, defaultPreference: true, editablePreference: true}] diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index a2e6165..62200e3 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -70,4 +70,11 @@ describe("Notifications Integration", () => { expect(type.Templates[0].TemplateSensitive).toBe("Book Ordered") expect(type.Templates[0].Subtitle).toBe("{{buyer}} ordered {{title}}") }) + + test("Email html is loaded from file with i18n resolved", () => { + const type = cds.notifications.local.types["bookshop/BookOrdered"]["1"] + expect(type.Templates[0].EmailHtml).toBe( + "Hi {{buyer}}, your order for {{title}} has been placed.
\n" + ) + }) }) \ No newline at end of file diff --git a/tests/unit/lib/compile.test.js b/tests/unit/lib/compile.test.js index 3e0a6e9..21e3ae5 100644 --- a/tests/unit/lib/compile.test.js +++ b/tests/unit/lib/compile.test.js @@ -1,9 +1,26 @@ +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), + readFileSync: jest.fn(), +})) + +const { existsSync, readFileSync } = require('fs') const { notificationTypesFromModel } = require("../../../lib/compile") function makeModel(defs) { return { definitions: Object.values(defs) } } +function makeEventWithHtml(html, file = 'srv/notifications.cds') { + return { + kind: 'event', + name: 'E', + '@notification.template.title': 't', + '@notification.template.email.html': html, + $location: { file, line: 1, col: 1 }, + } +} + describe("notificationTypesFromModel", () => { let originalI18nDescriptor @@ -233,13 +250,157 @@ describe("notificationTypesFromModel", () => { test("Resolve {i18n>KEY} in subtitle field", () => { Object.defineProperty(cds, 'i18n', { - value: { labels: { at: (key) => key === 'SUBTITLE_KEY' ? 'Resolved Subtitle' : undefined } }, + value: { labels: { at: (key) => key === 'BOOK_ORDERED_SUBTITLE' ? '{{buyer}} ordered {{title}}' : undefined } }, + configurable: true, writable: true + }) + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "t", "@notification.template.subtitle": "{i18n>BOOK_ORDERED_SUBTITLE}" } + }) + const [type] = notificationTypesFromModel(model) + expect(type.Templates[0].Subtitle).toBe("{{buyer}} ordered {{title}}") + }) + + test("Resolve {i18n>KEY} embedded within inline html string", () => { + const cds = require('@sap/cds') + Object.defineProperty(cds, 'i18n', { + value: { labels: { at: (key) => key === 'BOOK_ORDERED_SUBTITLE' ? '{{buyer}} ordered {{title}}' : undefined } }, configurable: true, writable: true }) const model = makeModel({ - "E": { kind: "event", name: "E", "@notification.template.title": "t", "@notification.template.subtitle": "{i18n>SUBTITLE_KEY}" } + "E": { kind: "event", name: "E", "@notification.template.email.html": "{i18n>BOOK_ORDERED_SUBTITLE}
" } + }) + const [type] = notificationTypesFromModel(model) + expect(type.Templates[0].EmailHtml).toBe("{{buyer}} ordered {{title}}
") + }) +}) + +describe("defaultEmailDelivery config", () => { + const cds = require('@sap/cds') + + afterEach(() => { + delete cds.env.requires?.notifications?.defaultEmailDelivery + }) + + test("Add MAIL delivery channel when defaultEmailDelivery is true and no channels annotated", () => { + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.defaultEmailDelivery = true + + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "t" } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels).toEqual([{ Type: 'MAIL', Enabled: true, DefaultPreference: true, EditablePreference: true }]) + }) + + test("Do not override explicit delivery channels when defaultEmailDelivery is true", () => { + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.defaultEmailDelivery = true + + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification.template.title": "t", + "@notification.deliveryChannels": [{ channel: "Web", enabled: false }] + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels).toEqual([{ Type: 'WEB', Enabled: false }]) + }) + + test("Do not add delivery channels when defaultEmailDelivery is false", () => { + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.defaultEmailDelivery = false + + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "t" } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels).toBeUndefined() + }) + + test("Do not add delivery channels when defaultEmailDelivery is not configured", () => { + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "t" } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels).toBeUndefined() + }) +}) + +describe("HTML file resolution", () => { + const cds = require('@sap/cds') + + beforeEach(() => { + jest.clearAllMocks() + }) + + test("Read html file when annotation value starts with ./", () => { + existsSync.mockReturnValue(true) + readFileSync.mockReturnValue('Hello {{buyer}}
') + + const model = makeModel({ "E": makeEventWithHtml('./email.html') }) + const [type] = notificationTypesFromModel(model) + + expect(type.Templates[0].EmailHtml).toBe('Hello {{buyer}}
') + expect(existsSync).toHaveBeenCalled() + expect(readFileSync).toHaveBeenCalled() + }) + + test("Read html file when annotation value starts with ../", () => { + existsSync.mockReturnValue(true) + readFileSync.mockReturnValue('content
') + + const model = makeModel({ "E": makeEventWithHtml('../templates/email.html') }) + const [type] = notificationTypesFromModel(model) + + expect(type.Templates[0].EmailHtml).toBe('content
') + }) + + test("Pass through inline html unchanged (no file read)", () => { + const model = makeModel({ "E": makeEventWithHtml('inline
') }) + const [type] = notificationTypesFromModel(model) + + expect(type.Templates[0].EmailHtml).toBe('inline
') + expect(existsSync).not.toHaveBeenCalled() + }) + + test("Returns annotation value as-is when html file not found", () => { + existsSync.mockReturnValue(false) + + const model = makeModel({ "E": makeEventWithHtml('./missing.html') }) + const [type] = notificationTypesFromModel(model) + + expect(type.Templates[0].EmailHtml).toBe('./missing.html') + expect(readFileSync).not.toHaveBeenCalled() + }) + + test("Resolves {i18n>KEY} placeholders inside html file content", () => { + Object.defineProperty(cds, 'i18n', { + value: { labels: { at: (key) => key === 'BOOK_ORDERED_DESCRIPTION' ? 'Book Ordered' : undefined } }, + configurable: true, writable: true }) + existsSync.mockReturnValue(true) + readFileSync.mockReturnValue('{i18n>BOOK_ORDERED_DESCRIPTION}
') + + const model = makeModel({ "E": makeEventWithHtml('./email.html') }) const [type] = notificationTypesFromModel(model) - expect(type.Templates[0].Subtitle).toBe("Resolved Subtitle") + + expect(type.Templates[0].EmailHtml).toBe('Book Ordered
') + }) + + test("Resolves html file path relative to the cds source file", () => { + existsSync.mockReturnValue(true) + readFileSync.mockReturnValue('hi
') + + const model = makeModel({ "E": makeEventWithHtml('./email.html', 'srv/notifications.cds') }) + notificationTypesFromModel(model) + + const calledPath = existsSync.mock.calls[0][0] + expect(calledPath).toMatch(/srv[/\\]email\.html$/) }) })