@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.
- package/dist/modules/auth/backend/auth/profile/page.js.map +1 -1
- package/dist/modules/auth/backend/roles/[id]/edit/page.js +4 -1
- package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.js +4 -1
- package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/cli.js +13 -12
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/business_rules/api/execute/route.js +7 -1
- package/dist/modules/business_rules/api/execute/route.js.map +2 -2
- package/dist/modules/business_rules/lib/rule-engine.js +33 -3
- package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
- package/dist/modules/dashboards/cli.js +12 -4
- package/dist/modules/dashboards/cli.js.map +2 -2
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +16 -11
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +3 -3
- package/dist/modules/dashboards/services/widgetDataService.js +46 -2
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +5 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +2 -1
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
- package/dist/modules/notifications/lib/deliveryConfig.js +4 -2
- package/dist/modules/notifications/lib/deliveryConfig.js.map +2 -2
- package/dist/modules/notifications/lib/deliveryStrategies.js +14 -0
- package/dist/modules/notifications/lib/deliveryStrategies.js.map +7 -0
- package/dist/modules/notifications/subscribers/deliver-notification.js +33 -7
- package/dist/modules/notifications/subscribers/deliver-notification.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +14 -6
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/package.json +2 -2
- package/src/modules/auth/README.md +1 -1
- package/src/modules/auth/__tests__/cli-setup-acl.test.ts +1 -1
- package/src/modules/auth/backend/auth/profile/page.tsx +2 -2
- package/src/modules/auth/backend/roles/[id]/edit/page.tsx +4 -1
- package/src/modules/auth/backend/users/[id]/edit/page.tsx +4 -1
- package/src/modules/auth/cli.ts +25 -12
- package/src/modules/business_rules/api/execute/route.ts +8 -1
- package/src/modules/business_rules/lib/__tests__/rule-engine.test.ts +51 -0
- package/src/modules/business_rules/lib/rule-engine.ts +57 -3
- package/src/modules/dashboards/cli.ts +14 -4
- package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +22 -11
- package/src/modules/dashboards/services/widgetDataService.ts +52 -2
- package/src/modules/notifications/__tests__/deliver-notification.test.ts +195 -0
- package/src/modules/notifications/__tests__/deliveryStrategies.test.ts +19 -0
- package/src/modules/notifications/__tests__/notificationService.test.ts +208 -0
- package/src/modules/notifications/data/validators.ts +5 -0
- package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +2 -0
- package/src/modules/notifications/lib/deliveryConfig.ts +8 -0
- package/src/modules/notifications/lib/deliveryStrategies.ts +50 -0
- package/src/modules/notifications/subscribers/deliver-notification.ts +39 -10
- 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
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
+
}
|