@open-mercato/core 0.4.2-canary-c84cff7ed5 → 0.4.2-canary-02d8ce2991

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 (51) 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/dashboards/cli.js +12 -4
  13. package/dist/modules/dashboards/cli.js.map +2 -2
  14. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +16 -11
  15. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +3 -3
  16. package/dist/modules/dashboards/services/widgetDataService.js +46 -2
  17. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  18. package/dist/modules/notifications/data/validators.js +5 -1
  19. package/dist/modules/notifications/data/validators.js.map +2 -2
  20. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +2 -1
  21. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
  22. package/dist/modules/notifications/lib/deliveryConfig.js +4 -2
  23. package/dist/modules/notifications/lib/deliveryConfig.js.map +2 -2
  24. package/dist/modules/notifications/lib/deliveryStrategies.js +14 -0
  25. package/dist/modules/notifications/lib/deliveryStrategies.js.map +7 -0
  26. package/dist/modules/notifications/subscribers/deliver-notification.js +33 -7
  27. package/dist/modules/notifications/subscribers/deliver-notification.js.map +2 -2
  28. package/dist/modules/workflows/lib/transition-handler.js +14 -6
  29. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  30. package/package.json +2 -2
  31. package/src/modules/auth/README.md +1 -1
  32. package/src/modules/auth/__tests__/cli-setup-acl.test.ts +1 -1
  33. package/src/modules/auth/backend/auth/profile/page.tsx +2 -2
  34. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +4 -1
  35. package/src/modules/auth/backend/users/[id]/edit/page.tsx +4 -1
  36. package/src/modules/auth/cli.ts +25 -12
  37. package/src/modules/business_rules/api/execute/route.ts +8 -1
  38. package/src/modules/business_rules/lib/__tests__/rule-engine.test.ts +51 -0
  39. package/src/modules/business_rules/lib/rule-engine.ts +57 -3
  40. package/src/modules/dashboards/cli.ts +14 -4
  41. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +22 -11
  42. package/src/modules/dashboards/services/widgetDataService.ts +52 -2
  43. package/src/modules/notifications/__tests__/deliver-notification.test.ts +195 -0
  44. package/src/modules/notifications/__tests__/deliveryStrategies.test.ts +19 -0
  45. package/src/modules/notifications/__tests__/notificationService.test.ts +208 -0
  46. package/src/modules/notifications/data/validators.ts +5 -0
  47. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +2 -0
  48. package/src/modules/notifications/lib/deliveryConfig.ts +8 -0
  49. package/src/modules/notifications/lib/deliveryStrategies.ts +50 -0
  50. package/src/modules/notifications/subscribers/deliver-notification.ts +39 -10
  51. package/src/modules/workflows/lib/transition-handler.ts +18 -6
@@ -44,9 +44,13 @@ type UserProps = BaseProps & {
44
44
 
45
45
  type WidgetVisibilityEditorProps = RoleProps | UserProps
46
46
 
47
+ export type WidgetVisibilityEditorHandle = {
48
+ save: () => Promise<void>
49
+ }
50
+
47
51
  const EMPTY: string[] = []
48
52
 
49
- export function WidgetVisibilityEditor(props: WidgetVisibilityEditorProps) {
53
+ export const WidgetVisibilityEditor = React.forwardRef<WidgetVisibilityEditorHandle, WidgetVisibilityEditorProps>(function WidgetVisibilityEditor(props, ref) {
50
54
  const t = useT()
51
55
  const { kind, targetId, tenantId, organizationId } = props
52
56
  const [catalog, setCatalog] = React.useState<WidgetCatalogItem[]>([])
@@ -60,6 +64,15 @@ export function WidgetVisibilityEditor(props: WidgetVisibilityEditorProps) {
60
64
  const [originalMode, setOriginalMode] = React.useState<'inherit' | 'override'>('inherit')
61
65
  const [effective, setEffective] = React.useState<string[]>(EMPTY)
62
66
 
67
+ const dirty = React.useMemo(() => {
68
+ if (kind === 'user') {
69
+ if (mode !== originalMode) return true
70
+ if (mode === 'override') return selected.join('|') !== original.join('|')
71
+ return false
72
+ }
73
+ return selected.join('|') !== original.join('|')
74
+ }, [kind, mode, original, originalMode, selected])
75
+
63
76
  const loadCatalog = React.useCallback(async () => {
64
77
  const data = await readApiResultOrThrow<{ items?: unknown[] }>(
65
78
  '/api/dashboards/widgets/catalog',
@@ -149,6 +162,9 @@ export function WidgetVisibilityEditor(props: WidgetVisibilityEditorProps) {
149
162
  }, [original, originalMode])
150
163
 
151
164
  const save = React.useCallback(async () => {
165
+ if (loading) return
166
+ if (error && catalog.length === 0) return
167
+ if (!dirty) return
152
168
  setSaving(true)
153
169
  setError(null)
154
170
  try {
@@ -202,16 +218,9 @@ export function WidgetVisibilityEditor(props: WidgetVisibilityEditorProps) {
202
218
  } finally {
203
219
  setSaving(false)
204
220
  }
205
- }, [kind, mode, organizationId, selected, t, targetId, tenantId])
221
+ }, [catalog.length, dirty, error, kind, loading, mode, organizationId, selected, t, targetId, tenantId])
206
222
 
207
- const dirty = React.useMemo(() => {
208
- if (kind === 'user') {
209
- if (mode !== originalMode) return true
210
- if (mode === 'override') return selected.join('|') !== original.join('|')
211
- return false
212
- }
213
- return selected.join('|') !== original.join('|')
214
- }, [kind, mode, original, originalMode, selected])
223
+ React.useImperativeHandle(ref, () => ({ save }), [save])
215
224
 
216
225
  if (loading) {
217
226
  return (
@@ -297,4 +306,6 @@ export function WidgetVisibilityEditor(props: WidgetVisibilityEditorProps) {
297
306
  </div>
298
307
  </div>
299
308
  )
300
- }
309
+ })
310
+
311
+ WidgetVisibilityEditor.displayName = 'WidgetVisibilityEditor'
@@ -1,6 +1,8 @@
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 { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
5
+ import { resolveEntityIdFromMetadata } from '@open-mercato/shared/lib/encryption/entityIds'
4
6
  import {
5
7
  type DateRangePreset,
6
8
  resolveDateRange,
@@ -296,11 +298,29 @@ export class WidgetDataService {
296
298
 
297
299
  try {
298
300
  const labelRows = await this.em.getConnection().execute(sql, params)
301
+ const meta = this.resolveEntityMetadata(config.table)
302
+ const entityId = this.resolveEntityId(meta)
303
+ const encryptionService = resolveTenantEncryptionService(this.em as any)
304
+ const organizationId = this.resolveOrganizationId()
299
305
 
300
306
  const labelMap = new Map<string, string>()
301
307
  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)
308
+ let labelValue = row.label
309
+ if (entityId && encryptionService?.isEnabled() && labelValue != null) {
310
+ const decrypted = await encryptionService.decryptEntityPayload(
311
+ entityId,
312
+ { [config.labelColumn]: labelValue },
313
+ this.scope.tenantId,
314
+ organizationId,
315
+ )
316
+ const resolved = decrypted[config.labelColumn]
317
+ if (typeof resolved === 'string' || typeof resolved === 'number') {
318
+ labelValue = String(resolved)
319
+ }
320
+ }
321
+
322
+ if (row.id && labelValue != null && labelValue !== '') {
323
+ labelMap.set(row.id, labelValue)
304
324
  }
305
325
  }
306
326
 
@@ -317,6 +337,36 @@ export class WidgetDataService {
317
337
  }))
318
338
  }
319
339
  }
340
+
341
+ private resolveOrganizationId(): string | null {
342
+ if (!this.scope.organizationIds || this.scope.organizationIds.length !== 1) return null
343
+ return this.scope.organizationIds[0] ?? null
344
+ }
345
+
346
+ private resolveEntityMetadata(tableName: string): Record<string, any> | null {
347
+ const registry = (this.em as any)?.getMetadata?.()
348
+ if (!registry) return null
349
+ const entries =
350
+ (typeof registry.getAll === 'function' && registry.getAll()) ||
351
+ (Array.isArray(registry.metadata) ? registry.metadata : Object.values(registry.metadata ?? {}))
352
+ const metas = Array.isArray(entries) ? entries : Object.values(entries ?? {})
353
+ const match = metas.find((meta: any) => {
354
+ const table = meta?.tableName ?? meta?.collection
355
+ if (typeof table !== 'string') return false
356
+ if (table === tableName) return true
357
+ return table.split('.').pop() === tableName
358
+ })
359
+ return match ?? null
360
+ }
361
+
362
+ private resolveEntityId(meta: Record<string, any> | null): string | null {
363
+ if (!meta) return null
364
+ try {
365
+ return resolveEntityIdFromMetadata(meta as any)
366
+ } catch {
367
+ return null
368
+ }
369
+ }
320
370
  }
321
371
 
322
372
  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
  }
@@ -0,0 +1,50 @@
1
+ import type { Notification } from '../data/entities'
2
+ import type { NotificationDeliveryConfig } from './deliveryConfig'
3
+
4
+ export type NotificationDeliveryStrategyConfig = {
5
+ enabled?: boolean
6
+ config?: unknown
7
+ }
8
+
9
+ export type NotificationDeliveryRecipient = {
10
+ email?: string | null
11
+ name?: string | null
12
+ }
13
+
14
+ export type NotificationDeliveryContext = {
15
+ notification: Notification
16
+ recipient: NotificationDeliveryRecipient
17
+ title: string
18
+ body: string | null
19
+ panelUrl: string | null
20
+ panelLink: string | null
21
+ actionLinks: Array<{ id: string; label: string; href: string }>
22
+ deliveryConfig: NotificationDeliveryConfig
23
+ config: NotificationDeliveryStrategyConfig
24
+ resolve: <T = unknown>(name: string) => T
25
+ t: (key: string, fallback?: string, variables?: Record<string, string>) => string
26
+ }
27
+
28
+ export type NotificationDeliveryStrategy = {
29
+ id: string
30
+ label?: string
31
+ defaultEnabled?: boolean
32
+ deliver: (ctx: NotificationDeliveryContext) => Promise<void> | void
33
+ }
34
+
35
+ type RegisteredStrategy = NotificationDeliveryStrategy & { priority: number }
36
+
37
+ const registry: RegisteredStrategy[] = []
38
+
39
+ export function registerNotificationDeliveryStrategy(
40
+ strategy: NotificationDeliveryStrategy,
41
+ options?: { priority?: number }
42
+ ): void {
43
+ const priority = options?.priority ?? 0
44
+ registry.push({ ...strategy, priority })
45
+ registry.sort((a, b) => b.priority - a.priority)
46
+ }
47
+
48
+ export function getNotificationDeliveryStrategies(): NotificationDeliveryStrategy[] {
49
+ return registry
50
+ }