@open-mercato/core 0.4.2-canary-49d47ff90e → 0.4.2-canary-0ba39cdeb6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/modules/auth/backend/auth/profile/page.js.map +1 -1
  2. package/dist/modules/auth/backend/roles/[id]/edit/page.js +4 -1
  3. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  4. package/dist/modules/auth/backend/users/[id]/edit/page.js +4 -1
  5. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  6. package/dist/modules/auth/cli.js +13 -12
  7. package/dist/modules/auth/cli.js.map +2 -2
  8. package/dist/modules/business_rules/api/execute/route.js +7 -1
  9. package/dist/modules/business_rules/api/execute/route.js.map +2 -2
  10. package/dist/modules/business_rules/lib/rule-engine.js +33 -3
  11. package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
  12. package/dist/modules/configs/components/CachePanel.js +4 -4
  13. package/dist/modules/configs/components/CachePanel.js.map +2 -2
  14. package/dist/modules/configs/lib/system-status.js +48 -1
  15. package/dist/modules/configs/lib/system-status.js.map +2 -2
  16. package/dist/modules/dashboards/cli.js +12 -4
  17. package/dist/modules/dashboards/cli.js.map +2 -2
  18. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +16 -11
  19. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +3 -3
  20. package/dist/modules/dashboards/services/widgetDataService.js +110 -3
  21. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  22. package/dist/modules/notifications/data/validators.js +5 -1
  23. package/dist/modules/notifications/data/validators.js.map +2 -2
  24. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +2 -1
  25. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
  26. package/dist/modules/notifications/lib/deliveryConfig.js +4 -2
  27. package/dist/modules/notifications/lib/deliveryConfig.js.map +2 -2
  28. package/dist/modules/notifications/lib/deliveryStrategies.js +14 -0
  29. package/dist/modules/notifications/lib/deliveryStrategies.js.map +7 -0
  30. package/dist/modules/notifications/subscribers/deliver-notification.js +33 -7
  31. package/dist/modules/notifications/subscribers/deliver-notification.js.map +2 -2
  32. package/dist/modules/workflows/lib/transition-handler.js +14 -6
  33. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  34. package/package.json +2 -2
  35. package/src/modules/auth/README.md +1 -1
  36. package/src/modules/auth/__tests__/cli-setup-acl.test.ts +1 -1
  37. package/src/modules/auth/backend/auth/profile/page.tsx +2 -2
  38. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +4 -1
  39. package/src/modules/auth/backend/users/[id]/edit/page.tsx +4 -1
  40. package/src/modules/auth/cli.ts +25 -12
  41. package/src/modules/business_rules/api/execute/route.ts +8 -1
  42. package/src/modules/business_rules/lib/__tests__/rule-engine.test.ts +51 -0
  43. package/src/modules/business_rules/lib/rule-engine.ts +57 -3
  44. package/src/modules/configs/components/CachePanel.tsx +4 -4
  45. package/src/modules/configs/i18n/en.json +12 -2
  46. package/src/modules/configs/i18n/pl.json +12 -2
  47. package/src/modules/configs/lib/system-status.ts +48 -1
  48. package/src/modules/configs/lib/system-status.types.ts +1 -0
  49. package/src/modules/dashboards/cli.ts +14 -4
  50. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +22 -11
  51. package/src/modules/dashboards/services/widgetDataService.ts +132 -4
  52. package/src/modules/notifications/__tests__/deliver-notification.test.ts +195 -0
  53. package/src/modules/notifications/__tests__/deliveryStrategies.test.ts +19 -0
  54. package/src/modules/notifications/__tests__/notificationService.test.ts +208 -0
  55. package/src/modules/notifications/data/validators.ts +5 -0
  56. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +2 -0
  57. package/src/modules/notifications/lib/deliveryConfig.ts +8 -0
  58. package/src/modules/notifications/lib/deliveryStrategies.ts +50 -0
  59. package/src/modules/notifications/subscribers/deliver-notification.ts +39 -10
  60. package/src/modules/workflows/lib/transition-handler.ts +18 -6
@@ -1,6 +1,10 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { CacheStrategy } from '@open-mercato/cache'
3
3
  import { createHash } from 'node:crypto'
4
+ import { decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
5
+ import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
6
+ import { resolveEntityIdFromMetadata } from '@open-mercato/shared/lib/encryption/entityIds'
7
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
8
  import {
5
9
  type DateRangePreset,
6
10
  resolveDateRange,
@@ -282,6 +286,57 @@ export class WidgetDataService {
282
286
  assertSafeIdentifier(config.idColumn, 'id column')
283
287
  assertSafeIdentifier(config.labelColumn, 'label column')
284
288
 
289
+ const meta = this.resolveEntityMetadata(config.table)
290
+ const idProp = meta ? this.resolveEntityPropertyName(meta, config.idColumn) : null
291
+ const labelProp = meta ? this.resolveEntityPropertyName(meta, config.labelColumn) : null
292
+ const tenantProp = meta
293
+ ? (this.resolveEntityPropertyName(meta, 'tenant_id') ?? this.resolveEntityPropertyName(meta, 'tenantId'))
294
+ : null
295
+ const organizationProp = meta
296
+ ? (this.resolveEntityPropertyName(meta, 'organization_id') ?? this.resolveEntityPropertyName(meta, 'organizationId'))
297
+ : null
298
+ const entityName = meta ? ((meta as any).class ?? meta.className ?? meta.name) : null
299
+
300
+ if (meta && idProp && labelProp && tenantProp && entityName) {
301
+ const where: Record<string, unknown> = {
302
+ [idProp]: { $in: uniqueIds },
303
+ [tenantProp]: this.scope.tenantId,
304
+ }
305
+ if (organizationProp && this.scope.organizationIds && this.scope.organizationIds.length > 0) {
306
+ where[organizationProp] = { $in: this.scope.organizationIds }
307
+ }
308
+
309
+ try {
310
+ const records = await findWithDecryption(
311
+ this.em,
312
+ entityName,
313
+ where,
314
+ { fields: [idProp, labelProp, tenantProp, organizationProp].filter(Boolean) },
315
+ { tenantId: this.scope.tenantId, organizationId: this.resolveOrganizationId() },
316
+ )
317
+
318
+ const labelMap = new Map<string, string>()
319
+ for (const record of records as Array<Record<string, unknown>>) {
320
+ const id = record[idProp]
321
+ const label = record[labelProp]
322
+ if (typeof id === 'string' && label != null && label !== '') {
323
+ labelMap.set(id, String(label))
324
+ }
325
+ }
326
+
327
+ if (labelMap.size > 0) {
328
+ return data.map((item) => ({
329
+ ...item,
330
+ groupLabel: typeof item.groupKey === 'string' && labelMap.has(item.groupKey)
331
+ ? labelMap.get(item.groupKey)!
332
+ : undefined,
333
+ }))
334
+ }
335
+ } catch {
336
+ // fall through to SQL resolution
337
+ }
338
+ }
339
+
285
340
  const clauses = [`"${config.idColumn}" = ANY(?::uuid[])`, 'tenant_id = ?']
286
341
  const params: unknown[] = [`{${uniqueIds.join(',')}}`, this.scope.tenantId]
287
342
 
@@ -290,17 +345,43 @@ export class WidgetDataService {
290
345
  params.push(`{${this.scope.organizationIds.join(',')}}`)
291
346
  }
292
347
 
293
- const sql = `SELECT "${config.idColumn}" as id, "${config.labelColumn}" as label FROM "${config.table}" WHERE ${clauses.join(
348
+ const sql = `SELECT "${config.idColumn}" as id, "${config.labelColumn}" as label, tenant_id, organization_id FROM "${config.table}" WHERE ${clauses.join(
294
349
  ' AND ',
295
350
  )}`
296
351
 
297
352
  try {
298
353
  const labelRows = await this.em.getConnection().execute(sql, params)
354
+ const entityId = this.resolveEntityId(meta)
355
+ const encryptionService = resolveTenantEncryptionService(this.em as any)
356
+ const organizationId = this.resolveOrganizationId()
357
+ const dek = encryptionService?.isEnabled() ? await encryptionService.getDek(this.scope.tenantId) : null
299
358
 
300
359
  const labelMap = new Map<string, string>()
301
- for (const row of labelRows as Array<{ id: string; label: string | null }>) {
302
- if (row.id && row.label != null && row.label !== '') {
303
- labelMap.set(row.id, row.label)
360
+ for (const row of labelRows as Array<{ id: string; label: string | null; tenant_id?: string | null; organization_id?: string | null }>) {
361
+ let labelValue = row.label
362
+ if (entityId && encryptionService?.isEnabled() && labelValue != null) {
363
+ const rowOrgId = row.organization_id ?? organizationId ?? null
364
+ const decrypted = await encryptionService.decryptEntityPayload(
365
+ entityId,
366
+ { [config.labelColumn]: labelValue },
367
+ this.scope.tenantId,
368
+ rowOrgId,
369
+ )
370
+ const resolved = decrypted[config.labelColumn]
371
+ if (typeof resolved === 'string' || typeof resolved === 'number') {
372
+ labelValue = String(resolved)
373
+ }
374
+ }
375
+
376
+ if (labelValue && dek?.key && this.isEncryptedPayload(labelValue)) {
377
+ const decrypted = decryptWithAesGcm(labelValue, dek.key)
378
+ if (decrypted !== null) {
379
+ labelValue = decrypted
380
+ }
381
+ }
382
+
383
+ if (row.id && labelValue != null && labelValue !== '') {
384
+ labelMap.set(row.id, labelValue)
304
385
  }
305
386
  }
306
387
 
@@ -317,6 +398,53 @@ export class WidgetDataService {
317
398
  }))
318
399
  }
319
400
  }
401
+
402
+ private resolveOrganizationId(): string | null {
403
+ if (!this.scope.organizationIds || this.scope.organizationIds.length !== 1) return null
404
+ return this.scope.organizationIds[0] ?? null
405
+ }
406
+
407
+ private resolveEntityMetadata(tableName: string): Record<string, any> | null {
408
+ const registry = (this.em as any)?.getMetadata?.()
409
+ if (!registry) return null
410
+ const entries =
411
+ (typeof registry.getAll === 'function' && registry.getAll()) ||
412
+ (Array.isArray(registry.metadata) ? registry.metadata : Object.values(registry.metadata ?? {}))
413
+ const metas = Array.isArray(entries) ? entries : Object.values(entries ?? {})
414
+ const match = metas.find((meta: any) => {
415
+ const table = meta?.tableName ?? meta?.collection
416
+ if (typeof table !== 'string') return false
417
+ if (table === tableName) return true
418
+ return table.split('.').pop() === tableName
419
+ })
420
+ return match ?? null
421
+ }
422
+
423
+ private resolveEntityPropertyName(meta: Record<string, any>, columnName: string): string | null {
424
+ const properties = meta?.properties ? Object.values(meta.properties) : []
425
+ for (const prop of properties as Array<Record<string, any>>) {
426
+ const fieldName = prop?.fieldName
427
+ const fieldNames = prop?.fieldNames
428
+ if (typeof fieldName === 'string' && fieldName === columnName) return prop?.name ?? null
429
+ if (Array.isArray(fieldNames) && fieldNames.includes(columnName)) return prop?.name ?? null
430
+ if (prop?.name === columnName) return prop?.name ?? null
431
+ }
432
+ return null
433
+ }
434
+
435
+ private resolveEntityId(meta: Record<string, any> | null): string | null {
436
+ if (!meta) return null
437
+ try {
438
+ return resolveEntityIdFromMetadata(meta as any)
439
+ } catch {
440
+ return null
441
+ }
442
+ }
443
+
444
+ private isEncryptedPayload(value: string): boolean {
445
+ const parts = value.split(':')
446
+ return parts.length === 4 && parts[3] === 'v1'
447
+ }
320
448
  }
321
449
 
322
450
  export function createWidgetDataService(
@@ -0,0 +1,195 @@
1
+ import type { Notification } from '../data/entities'
2
+ import type { NotificationDeliveryConfig } from '../lib/deliveryConfig'
3
+ import type { NotificationDeliveryStrategy } from '../lib/deliveryStrategies'
4
+
5
+ const sendEmail = jest.fn()
6
+ const getNotificationDeliveryStrategies = jest.fn()
7
+ const resolveNotificationDeliveryConfig = jest.fn()
8
+ const resolveNotificationPanelUrl = jest.fn()
9
+ const findOneWithDecryption = jest.fn()
10
+ const NotificationEmail = jest.fn(() => 'notification-email')
11
+
12
+ jest.mock('@open-mercato/shared/lib/email/send', () => ({
13
+ sendEmail: (...args: unknown[]) => sendEmail(...args),
14
+ }))
15
+
16
+ jest.mock('../lib/deliveryStrategies', () => ({
17
+ getNotificationDeliveryStrategies: (...args: unknown[]) => getNotificationDeliveryStrategies(...args),
18
+ }))
19
+
20
+ jest.mock('../lib/deliveryConfig', () => ({
21
+ DEFAULT_NOTIFICATION_DELIVERY_CONFIG: {
22
+ panelPath: '/backend/notifications',
23
+ strategies: {
24
+ database: { enabled: true },
25
+ email: { enabled: true },
26
+ custom: {},
27
+ },
28
+ },
29
+ resolveNotificationDeliveryConfig: (...args: unknown[]) => resolveNotificationDeliveryConfig(...args),
30
+ resolveNotificationPanelUrl: (...args: unknown[]) => resolveNotificationPanelUrl(...args),
31
+ }))
32
+
33
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
34
+ loadDictionary: jest.fn().mockResolvedValue({}),
35
+ }))
36
+
37
+ jest.mock('@open-mercato/shared/lib/i18n/translate', () => ({
38
+ createFallbackTranslator: () => (key: string, fallback?: string) => fallback ?? key,
39
+ }))
40
+
41
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
42
+ findOneWithDecryption: (...args: unknown[]) => findOneWithDecryption(...args),
43
+ }))
44
+
45
+ jest.mock('../emails/NotificationEmail', () => ({
46
+ __esModule: true,
47
+ default: (...args: unknown[]) => NotificationEmail(...args),
48
+ }))
49
+
50
+ describe('deliver notification subscriber', () => {
51
+ const notification: Notification = {
52
+ id: '32e22a6e-7aa9-4f3b-8a7b-42c2d1223a5f',
53
+ recipientUserId: '3eae68bb-1e4c-4b21-85c7-b8f5b2c22b01',
54
+ tenantId: 'c33b6f78-8c4b-4ef4-9c54-2b64b5f5d0d0',
55
+ organizationId: null,
56
+ type: 'system',
57
+ title: 'New notification',
58
+ body: 'Check details',
59
+ titleKey: null,
60
+ bodyKey: null,
61
+ titleVariables: null,
62
+ bodyVariables: null,
63
+ icon: null,
64
+ severity: 'info',
65
+ actionData: {
66
+ actions: [{ id: 'action-1', label: 'Review' }],
67
+ primaryActionId: 'action-1',
68
+ },
69
+ sourceModule: null,
70
+ sourceEntityType: null,
71
+ sourceEntityId: null,
72
+ linkHref: null,
73
+ groupKey: null,
74
+ status: 'unread',
75
+ readAt: null,
76
+ actionedAt: null,
77
+ dismissedAt: null,
78
+ createdAt: new Date(),
79
+ updatedAt: new Date(),
80
+ expiresAt: null,
81
+ actionTaken: null,
82
+ actionResult: null,
83
+ } as Notification
84
+
85
+ const baseConfig: NotificationDeliveryConfig = {
86
+ appUrl: 'https://app.example.com',
87
+ panelPath: '/backend/notifications',
88
+ strategies: {
89
+ database: { enabled: true },
90
+ email: {
91
+ enabled: true,
92
+ from: 'notifications@example.com',
93
+ replyTo: 'reply@example.com',
94
+ subjectPrefix: '[OM]',
95
+ },
96
+ custom: {},
97
+ },
98
+ }
99
+
100
+ beforeEach(() => {
101
+ jest.clearAllMocks()
102
+ })
103
+
104
+ it('sends email notifications when enabled', async () => {
105
+ resolveNotificationDeliveryConfig.mockResolvedValue(baseConfig)
106
+ resolveNotificationPanelUrl.mockReturnValue('https://app.example.com/backend/notifications')
107
+ getNotificationDeliveryStrategies.mockReturnValue([])
108
+ findOneWithDecryption.mockResolvedValue({ email: 'user@example.com', name: 'User' })
109
+
110
+ const em = {
111
+ findOne: jest.fn().mockResolvedValue(notification),
112
+ }
113
+
114
+ const { default: handle } = await import('../subscribers/deliver-notification')
115
+
116
+ await handle(
117
+ {
118
+ notificationId: notification.id,
119
+ recipientUserId: notification.recipientUserId,
120
+ tenantId: notification.tenantId,
121
+ organizationId: null,
122
+ },
123
+ {
124
+ resolve: (name: string) => {
125
+ if (name === 'em') return em
126
+ throw new Error(`Missing dependency: ${name}`)
127
+ },
128
+ }
129
+ )
130
+
131
+ expect(NotificationEmail).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ title: notification.title,
134
+ panelUrl: `https://app.example.com/backend/notifications?notificationId=${notification.id}`,
135
+ })
136
+ )
137
+ expect(sendEmail).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ to: 'user@example.com',
140
+ from: baseConfig.strategies.email.from,
141
+ replyTo: baseConfig.strategies.email.replyTo,
142
+ subject: `${baseConfig.strategies.email.subjectPrefix} ${notification.title}`,
143
+ })
144
+ )
145
+ })
146
+
147
+ it('executes enabled custom delivery strategies', async () => {
148
+ const customStrategy: NotificationDeliveryStrategy = {
149
+ id: 'webhook',
150
+ deliver: jest.fn(),
151
+ }
152
+ resolveNotificationDeliveryConfig.mockResolvedValue({
153
+ ...baseConfig,
154
+ strategies: {
155
+ ...baseConfig.strategies,
156
+ email: { enabled: false },
157
+ custom: {
158
+ webhook: { enabled: true, config: { url: 'https://hooks.example.com' } },
159
+ },
160
+ },
161
+ })
162
+ resolveNotificationPanelUrl.mockReturnValue('/backend/notifications')
163
+ getNotificationDeliveryStrategies.mockReturnValue([customStrategy])
164
+ findOneWithDecryption.mockResolvedValue({ email: 'user@example.com', name: 'User' })
165
+
166
+ const em = {
167
+ findOne: jest.fn().mockResolvedValue(notification),
168
+ }
169
+
170
+ const { default: handle } = await import('../subscribers/deliver-notification')
171
+
172
+ await handle(
173
+ {
174
+ notificationId: notification.id,
175
+ recipientUserId: notification.recipientUserId,
176
+ tenantId: notification.tenantId,
177
+ organizationId: null,
178
+ },
179
+ {
180
+ resolve: (name: string) => {
181
+ if (name === 'em') return em
182
+ throw new Error(`Missing dependency: ${name}`)
183
+ },
184
+ }
185
+ )
186
+
187
+ expect(customStrategy.deliver).toHaveBeenCalledWith(
188
+ expect.objectContaining({
189
+ config: { enabled: true, config: { url: 'https://hooks.example.com' } },
190
+ panelLink: `/backend/notifications?notificationId=${notification.id}`,
191
+ })
192
+ )
193
+ expect(sendEmail).not.toHaveBeenCalled()
194
+ })
195
+ })
@@ -0,0 +1,19 @@
1
+ import { type NotificationDeliveryStrategy } from '../lib/deliveryStrategies'
2
+
3
+ describe('notification delivery strategies', () => {
4
+ it('orders strategies by priority', async () => {
5
+ jest.resetModules()
6
+ const { registerNotificationDeliveryStrategy, getNotificationDeliveryStrategies } = await import('../lib/deliveryStrategies')
7
+
8
+ const first: NotificationDeliveryStrategy = { id: 'first', deliver: jest.fn() }
9
+ const second: NotificationDeliveryStrategy = { id: 'second', deliver: jest.fn() }
10
+ const third: NotificationDeliveryStrategy = { id: 'third', deliver: jest.fn() }
11
+
12
+ registerNotificationDeliveryStrategy(first, { priority: 1 })
13
+ registerNotificationDeliveryStrategy(second, { priority: 10 })
14
+ registerNotificationDeliveryStrategy(third, { priority: 5 })
15
+
16
+ const ids = getNotificationDeliveryStrategies().map((strategy) => strategy.id)
17
+ expect(ids).toEqual(['second', 'third', 'first'])
18
+ })
19
+ })
@@ -0,0 +1,208 @@
1
+ import { createNotificationService } from '../lib/notificationService'
2
+ import { NOTIFICATION_EVENTS } from '../lib/events'
3
+ import type { Notification } from '../data/entities'
4
+ import { getRecipientUserIdsForFeature } from '../lib/notificationRecipients'
5
+
6
+ jest.mock('../lib/notificationRecipients', () => ({
7
+ getRecipientUserIdsForRole: jest.fn(),
8
+ getRecipientUserIdsForFeature: jest.fn(),
9
+ }))
10
+
11
+ const baseNotificationInput = {
12
+ type: 'system',
13
+ title: 'Hello',
14
+ recipientUserId: '2d4a4c33-9c4b-4e39-8e15-0a3cd9a7f432',
15
+ } as const
16
+
17
+ const baseCtx = {
18
+ tenantId: '7f4c85ef-f8f7-4e53-9df1-42e95bd8d48e',
19
+ organizationId: null,
20
+ userId: '2d4a4c33-9c4b-4e39-8e15-0a3cd9a7f432',
21
+ }
22
+
23
+ const buildEm = () => {
24
+ const em = {
25
+ fork: jest.fn(),
26
+ create: jest.fn(),
27
+ persistAndFlush: jest.fn(),
28
+ flush: jest.fn(),
29
+ findOneOrFail: jest.fn(),
30
+ count: jest.fn(),
31
+ find: jest.fn(),
32
+ getConnection: jest.fn(),
33
+ }
34
+ em.fork.mockReturnValue(em)
35
+ em.getConnection.mockReturnValue({
36
+ getKnex: () => ({}),
37
+ })
38
+ return em
39
+ }
40
+
41
+ describe('notification service', () => {
42
+ beforeEach(() => {
43
+ jest.clearAllMocks()
44
+ })
45
+
46
+ it('creates a notification and emits event', async () => {
47
+ const em = buildEm()
48
+ const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
49
+
50
+ em.create.mockImplementation((_entity, data: Notification) => ({
51
+ id: 'note-1',
52
+ ...data,
53
+ }))
54
+
55
+ const service = createNotificationService({ em, eventBus })
56
+
57
+ const notification = await service.create(baseNotificationInput, baseCtx)
58
+
59
+ expect(notification.id).toBe('note-1')
60
+ expect(em.persistAndFlush).toHaveBeenCalledWith(notification)
61
+ expect(eventBus.emit).toHaveBeenCalledWith(
62
+ NOTIFICATION_EVENTS.CREATED,
63
+ expect.objectContaining({
64
+ notificationId: notification.id,
65
+ recipientUserId: baseNotificationInput.recipientUserId,
66
+ tenantId: baseCtx.tenantId,
67
+ })
68
+ )
69
+ })
70
+
71
+ it('creates batch notifications and emits events for each', async () => {
72
+ const em = buildEm()
73
+ const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
74
+
75
+ em.create.mockImplementation((_entity, data: Notification) => ({
76
+ id: `note-${data.recipientUserId}`,
77
+ ...data,
78
+ }))
79
+
80
+ const service = createNotificationService({ em, eventBus })
81
+
82
+ const notifications = await service.createBatch(
83
+ {
84
+ type: 'system',
85
+ title: 'Hello',
86
+ recipientUserIds: ['e2c9ac54-ecdb-4d79-8d73-8328ca0f16f0', 'e2d9e79c-3f2f-4b8c-9455-6c19b671dc5c'],
87
+ },
88
+ baseCtx
89
+ )
90
+
91
+ expect(notifications).toHaveLength(2)
92
+ expect(em.persistAndFlush).toHaveBeenCalledWith(notifications)
93
+ expect(eventBus.emit).toHaveBeenCalledTimes(2)
94
+ })
95
+
96
+ it('returns empty list when no recipients match feature', async () => {
97
+ const em = buildEm()
98
+ const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
99
+ const service = createNotificationService({ em, eventBus })
100
+
101
+ ;(getRecipientUserIdsForFeature as jest.Mock).mockResolvedValue([])
102
+
103
+ const result = await service.createForFeature(
104
+ {
105
+ type: 'system',
106
+ title: 'Hello',
107
+ requiredFeature: 'notifications.view',
108
+ },
109
+ baseCtx
110
+ )
111
+
112
+ expect(result).toEqual([])
113
+ expect(em.persistAndFlush).not.toHaveBeenCalled()
114
+ expect(eventBus.emit).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('marks a notification as read and emits event', async () => {
118
+ const em = buildEm()
119
+ const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
120
+ const service = createNotificationService({ em, eventBus })
121
+
122
+ const notification: Notification = {
123
+ id: 'note-2',
124
+ recipientUserId: baseCtx.userId ?? null,
125
+ tenantId: baseCtx.tenantId,
126
+ status: 'unread',
127
+ readAt: null,
128
+ } as Notification
129
+
130
+ em.findOneOrFail.mockResolvedValue(notification)
131
+
132
+ const result = await service.markAsRead(notification.id, baseCtx)
133
+
134
+ expect(result.status).toBe('read')
135
+ expect(result.readAt).toBeInstanceOf(Date)
136
+ expect(em.flush).toHaveBeenCalled()
137
+ expect(eventBus.emit).toHaveBeenCalledWith(
138
+ NOTIFICATION_EVENTS.READ,
139
+ expect.objectContaining({
140
+ notificationId: notification.id,
141
+ userId: baseCtx.userId,
142
+ tenantId: baseCtx.tenantId,
143
+ })
144
+ )
145
+ })
146
+
147
+ it('executes notification action via command bus', async () => {
148
+ const em = buildEm()
149
+ const eventBus = { emit: jest.fn().mockResolvedValue(undefined) }
150
+ const commandBus = { execute: jest.fn().mockResolvedValue({ result: { ok: true } }) }
151
+ const container = { resolve: jest.fn() }
152
+ const service = createNotificationService({ em, eventBus, commandBus, container })
153
+
154
+ const notification: Notification = {
155
+ id: 'note-3',
156
+ recipientUserId: baseCtx.userId ?? null,
157
+ tenantId: baseCtx.tenantId,
158
+ status: 'unread',
159
+ readAt: null,
160
+ sourceEntityId: '1f9d8d1c-319f-48d4-b803-77665b6b2510',
161
+ actionData: {
162
+ actions: [
163
+ {
164
+ id: 'approve',
165
+ label: 'Approve',
166
+ commandId: 'sales.approve',
167
+ },
168
+ ],
169
+ primaryActionId: 'approve',
170
+ },
171
+ } as Notification
172
+
173
+ em.findOneOrFail.mockResolvedValue(notification)
174
+
175
+ const result = await service.executeAction(
176
+ notification.id,
177
+ { actionId: 'approve', payload: { note: 'ok' } },
178
+ baseCtx
179
+ )
180
+
181
+ expect(commandBus.execute).toHaveBeenCalledWith(
182
+ 'sales.approve',
183
+ expect.objectContaining({
184
+ input: expect.objectContaining({
185
+ id: notification.sourceEntityId,
186
+ note: 'ok',
187
+ }),
188
+ metadata: expect.objectContaining({
189
+ tenantId: baseCtx.tenantId,
190
+ organizationId: baseCtx.organizationId,
191
+ resourceKind: 'notifications',
192
+ }),
193
+ })
194
+ )
195
+ expect(result.result).toEqual({ ok: true })
196
+ expect(notification.status).toBe('actioned')
197
+ expect(notification.actionTaken).toBe('approve')
198
+ expect(eventBus.emit).toHaveBeenCalledWith(
199
+ NOTIFICATION_EVENTS.ACTIONED,
200
+ expect.objectContaining({
201
+ notificationId: notification.id,
202
+ actionId: 'approve',
203
+ userId: baseCtx.userId,
204
+ tenantId: baseCtx.tenantId,
205
+ })
206
+ )
207
+ })
208
+ })
@@ -92,12 +92,17 @@ const notificationDeliveryEmailSchema = notificationDeliveryStrategySchema.exten
92
92
  subjectPrefix: z.string().trim().min(1).optional(),
93
93
  })
94
94
 
95
+ const notificationDeliveryCustomSchema = notificationDeliveryStrategySchema.extend({
96
+ config: z.unknown().optional(),
97
+ })
98
+
95
99
  export const notificationDeliveryConfigSchema = z.object({
96
100
  appUrl: z.string().url().optional(),
97
101
  panelPath: safeRelativeHrefSchema.optional(),
98
102
  strategies: z.object({
99
103
  database: notificationDeliveryStrategySchema.optional(),
100
104
  email: notificationDeliveryEmailSchema.optional(),
105
+ custom: z.record(z.string(), notificationDeliveryCustomSchema).optional(),
101
106
  }).optional(),
102
107
  })
103
108
 
@@ -17,6 +17,7 @@ type NotificationDeliveryConfig = {
17
17
  strategies: {
18
18
  database: { enabled: boolean }
19
19
  email: { enabled: boolean; from?: string; replyTo?: string; subjectPrefix?: string }
20
+ custom?: Record<string, { enabled?: boolean; config?: unknown }>
20
21
  }
21
22
  }
22
23
 
@@ -30,6 +31,7 @@ const emptySettings: NotificationDeliveryConfig = {
30
31
  strategies: {
31
32
  database: { enabled: true },
32
33
  email: { enabled: true },
34
+ custom: {},
33
35
  },
34
36
  }
35
37
 
@@ -8,6 +8,11 @@ export type NotificationDeliveryStrategyState = {
8
8
  enabled: boolean
9
9
  }
10
10
 
11
+ export type NotificationCustomDeliveryConfig = {
12
+ enabled?: boolean
13
+ config?: unknown
14
+ }
15
+
11
16
  export type NotificationEmailDeliveryConfig = NotificationDeliveryStrategyState & {
12
17
  from?: string
13
18
  replyTo?: string
@@ -20,6 +25,7 @@ export type NotificationDeliveryConfig = {
20
25
  strategies: {
21
26
  database: NotificationDeliveryStrategyState
22
27
  email: NotificationEmailDeliveryConfig
28
+ custom?: Record<string, NotificationCustomDeliveryConfig>
23
29
  }
24
30
  }
25
31
 
@@ -65,6 +71,7 @@ export const DEFAULT_NOTIFICATION_DELIVERY_CONFIG: NotificationDeliveryConfig =
65
71
  replyTo: env.emailReplyTo,
66
72
  subjectPrefix: env.emailSubjectPrefix,
67
73
  },
74
+ custom: {},
68
75
  },
69
76
  }
70
77
  })()
@@ -91,6 +98,7 @@ const normalizeDeliveryConfig = (input?: unknown | null): NotificationDeliveryCo
91
98
  replyTo: strategies.email?.replyTo,
92
99
  subjectPrefix: strategies.email?.subjectPrefix,
93
100
  },
101
+ custom: strategies.custom ?? {},
94
102
  },
95
103
  }
96
104
  }