@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.
- 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/configs/components/CachePanel.js +4 -4
- package/dist/modules/configs/components/CachePanel.js.map +2 -2
- package/dist/modules/configs/lib/system-status.js +48 -1
- package/dist/modules/configs/lib/system-status.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 +110 -3
- 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/configs/components/CachePanel.tsx +4 -4
- package/src/modules/configs/i18n/en.json +12 -2
- package/src/modules/configs/i18n/pl.json +12 -2
- package/src/modules/configs/lib/system-status.ts +48 -1
- package/src/modules/configs/lib/system-status.types.ts +1 -0
- package/src/modules/dashboards/cli.ts +14 -4
- package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +22 -11
- package/src/modules/dashboards/services/widgetDataService.ts +132 -4
- 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
|
@@ -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
|
-
|
|
303
|
-
|
|
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
|
}
|