@open-mercato/core 0.4.2-canary-36ab8921da → 0.4.2-canary-07dbc98202
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/generated/entities/notification/index.js +57 -0
- package/dist/generated/entities/notification/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +63 -59
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/api_docs/frontend/docs/api/page.js +3 -2
- package/dist/modules/api_docs/frontend/docs/api/page.js.map +2 -2
- package/dist/modules/auth/api/admin/nav.js +4 -3
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/profile/route.js +155 -0
- package/dist/modules/auth/api/profile/route.js.map +7 -0
- package/dist/modules/auth/api/reset/confirm.js +25 -2
- package/dist/modules/auth/api/reset/confirm.js.map +2 -2
- package/dist/modules/auth/api/reset.js +23 -0
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/api/sidebar/preferences/route.js +14 -9
- package/dist/modules/auth/api/sidebar/preferences/route.js.map +2 -2
- package/dist/modules/auth/backend/auth/profile/page.js +99 -0
- package/dist/modules/auth/backend/auth/profile/page.js.map +7 -0
- package/dist/modules/auth/backend/auth/profile/page.meta.js +12 -0
- package/dist/modules/auth/backend/auth/profile/page.meta.js.map +7 -0
- package/dist/modules/auth/commands/users.js +55 -0
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +1 -0
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/notifications.js +112 -0
- package/dist/modules/auth/notifications.js.map +7 -0
- package/dist/modules/auth/services/authService.js +3 -3
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/business_rules/notifications.js +28 -0
- package/dist/modules/business_rules/notifications.js.map +7 -0
- package/dist/modules/business_rules/subscribers/rule-execution-failed-notification.js +37 -0
- package/dist/modules/business_rules/subscribers/rule-execution-failed-notification.js.map +7 -0
- package/dist/modules/catalog/notifications.js +28 -0
- package/dist/modules/catalog/notifications.js.map +7 -0
- package/dist/modules/catalog/subscribers/low-stock-notification.js +38 -0
- package/dist/modules/catalog/subscribers/low-stock-notification.js.map +7 -0
- package/dist/modules/configs/cli.js +6 -0
- package/dist/modules/configs/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +31 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/notifications.js +48 -0
- package/dist/modules/customers/notifications.js.map +7 -0
- package/dist/modules/notifications/acl.js +11 -0
- package/dist/modules/notifications/acl.js.map +7 -0
- package/dist/modules/notifications/api/[id]/action/route.js +74 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +7 -0
- package/dist/modules/notifications/api/[id]/dismiss/route.js +15 -0
- package/dist/modules/notifications/api/[id]/dismiss/route.js.map +7 -0
- package/dist/modules/notifications/api/[id]/read/route.js +15 -0
- package/dist/modules/notifications/api/[id]/read/route.js.map +7 -0
- package/dist/modules/notifications/api/[id]/restore/route.js +53 -0
- package/dist/modules/notifications/api/[id]/restore/route.js.map +7 -0
- package/dist/modules/notifications/api/batch/route.js +17 -0
- package/dist/modules/notifications/api/batch/route.js.map +7 -0
- package/dist/modules/notifications/api/feature/route.js +17 -0
- package/dist/modules/notifications/api/feature/route.js.map +7 -0
- package/dist/modules/notifications/api/mark-all-read/route.js +35 -0
- package/dist/modules/notifications/api/mark-all-read/route.js.map +7 -0
- package/dist/modules/notifications/api/openapi.js +76 -0
- package/dist/modules/notifications/api/openapi.js.map +7 -0
- package/dist/modules/notifications/api/role/route.js +17 -0
- package/dist/modules/notifications/api/role/route.js.map +7 -0
- package/dist/modules/notifications/api/route.js +85 -0
- package/dist/modules/notifications/api/route.js.map +7 -0
- package/dist/modules/notifications/api/settings/route.js +155 -0
- package/dist/modules/notifications/api/settings/route.js.map +7 -0
- package/dist/modules/notifications/api/unread-count/route.js +38 -0
- package/dist/modules/notifications/api/unread-count/route.js.map +7 -0
- package/dist/modules/notifications/backend/config/notifications/page.js +10 -0
- package/dist/modules/notifications/backend/config/notifications/page.js.map +7 -0
- package/dist/modules/notifications/backend/config/notifications/page.meta.js +24 -0
- package/dist/modules/notifications/backend/config/notifications/page.meta.js.map +7 -0
- package/dist/modules/notifications/cli.js +16 -0
- package/dist/modules/notifications/cli.js.map +7 -0
- package/dist/modules/notifications/data/entities.js +112 -0
- package/dist/modules/notifications/data/entities.js.map +7 -0
- package/dist/modules/notifications/data/validators.js +94 -0
- package/dist/modules/notifications/data/validators.js.map +7 -0
- package/dist/modules/notifications/di.js +13 -0
- package/dist/modules/notifications/di.js.map +7 -0
- package/dist/modules/notifications/emails/NotificationEmail.js +58 -0
- package/dist/modules/notifications/emails/NotificationEmail.js.map +7 -0
- package/dist/modules/notifications/frontend/NotificationInboxPageClient.js +44 -0
- package/dist/modules/notifications/frontend/NotificationInboxPageClient.js.map +7 -0
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +219 -0
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +7 -0
- package/dist/modules/notifications/index.js +14 -0
- package/dist/modules/notifications/index.js.map +7 -0
- package/dist/modules/notifications/lib/deliveryConfig.js +105 -0
- package/dist/modules/notifications/lib/deliveryConfig.js.map +7 -0
- package/dist/modules/notifications/lib/events.js +12 -0
- package/dist/modules/notifications/lib/events.js.map +7 -0
- package/dist/modules/notifications/lib/notificationBuilder.js +66 -0
- package/dist/modules/notifications/lib/notificationBuilder.js.map +7 -0
- package/dist/modules/notifications/lib/notificationFactory.js +54 -0
- package/dist/modules/notifications/lib/notificationFactory.js.map +7 -0
- package/dist/modules/notifications/lib/notificationMapper.js +34 -0
- package/dist/modules/notifications/lib/notificationMapper.js.map +7 -0
- package/dist/modules/notifications/lib/notificationRecipients.js +35 -0
- package/dist/modules/notifications/lib/notificationRecipients.js.map +7 -0
- package/dist/modules/notifications/lib/notificationService.js +279 -0
- package/dist/modules/notifications/lib/notificationService.js.map +7 -0
- package/dist/modules/notifications/lib/routeHelpers.js +101 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +7 -0
- package/dist/modules/notifications/lib/safeHref.js +24 -0
- package/dist/modules/notifications/lib/safeHref.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260123000001.js +70 -0
- package/dist/modules/notifications/migrations/Migration20260123000001.js.map +7 -0
- package/dist/modules/notifications/migrations/Migration20260126150000.js +37 -0
- package/dist/modules/notifications/migrations/Migration20260126150000.js.map +7 -0
- package/dist/modules/notifications/subscribers/deliver-notification.js +139 -0
- package/dist/modules/notifications/subscribers/deliver-notification.js.map +7 -0
- package/dist/modules/notifications/workers/create-notification.worker.js +70 -0
- package/dist/modules/notifications/workers/create-notification.worker.js.map +7 -0
- package/dist/modules/sales/commands/documents.js +53 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/payments.js +26 -0
- package/dist/modules/sales/commands/payments.js.map +2 -2
- package/dist/modules/sales/notifications.client.js +51 -0
- package/dist/modules/sales/notifications.client.js.map +7 -0
- package/dist/modules/sales/notifications.js +88 -0
- package/dist/modules/sales/notifications.js.map +7 -0
- package/dist/modules/sales/subscribers/quote-expiring-notification.js +38 -0
- package/dist/modules/sales/subscribers/quote-expiring-notification.js.map +7 -0
- package/dist/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.js +137 -0
- package/dist/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.js.map +7 -0
- package/dist/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.js +137 -0
- package/dist/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.js.map +7 -0
- package/dist/modules/sales/widgets/notifications/index.js +7 -0
- package/dist/modules/sales/widgets/notifications/index.js.map +7 -0
- package/dist/modules/sales/widgets/notifications/useSalesDocumentTotals.js +60 -0
- package/dist/modules/sales/widgets/notifications/useSalesDocumentTotals.js.map +7 -0
- package/dist/modules/staff/commands/leave-requests.js +79 -0
- package/dist/modules/staff/commands/leave-requests.js.map +2 -2
- package/dist/modules/staff/notifications.js +75 -0
- package/dist/modules/staff/notifications.js.map +7 -0
- package/dist/modules/workflows/notifications.js +28 -0
- package/dist/modules/workflows/notifications.js.map +7 -0
- package/dist/modules/workflows/subscribers/task-assigned-notification.js +38 -0
- package/dist/modules/workflows/subscribers/task-assigned-notification.js.map +7 -0
- package/generated/entities/notification/index.ts +27 -0
- package/generated/entities.ids.generated.ts +63 -59
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +2 -2
- package/src/modules/api_docs/frontend/docs/api/page.tsx +3 -2
- package/src/modules/auth/api/admin/nav.ts +10 -6
- package/src/modules/auth/api/profile/route.ts +160 -0
- package/src/modules/auth/api/reset/confirm.ts +25 -2
- package/src/modules/auth/api/reset.ts +23 -0
- package/src/modules/auth/api/sidebar/preferences/route.ts +21 -12
- package/src/modules/auth/backend/auth/profile/page.meta.ts +8 -0
- package/src/modules/auth/backend/auth/profile/page.tsx +127 -0
- package/src/modules/auth/commands/users.ts +68 -0
- package/src/modules/auth/i18n/de.json +29 -1
- package/src/modules/auth/i18n/en.json +29 -1
- package/src/modules/auth/i18n/es.json +29 -1
- package/src/modules/auth/i18n/pl.json +29 -1
- package/src/modules/auth/lib/setup-app.ts +1 -0
- package/src/modules/auth/notifications.ts +109 -0
- package/src/modules/auth/services/authService.ts +4 -4
- package/src/modules/business_rules/i18n/en.json +3 -1
- package/src/modules/business_rules/notifications.ts +25 -0
- package/src/modules/business_rules/subscribers/rule-execution-failed-notification.ts +50 -0
- package/src/modules/catalog/i18n/en.json +3 -1
- package/src/modules/catalog/notifications.ts +25 -0
- package/src/modules/catalog/subscribers/low-stock-notification.ts +52 -0
- package/src/modules/configs/cli.ts +6 -0
- package/src/modules/customers/commands/deals.ts +39 -0
- package/src/modules/customers/i18n/en.json +5 -1
- package/src/modules/customers/notifications.ts +44 -0
- package/src/modules/notifications/acl.ts +7 -0
- package/src/modules/notifications/api/[id]/action/route.ts +75 -0
- package/src/modules/notifications/api/[id]/dismiss/route.ts +12 -0
- package/src/modules/notifications/api/[id]/read/route.ts +12 -0
- package/src/modules/notifications/api/[id]/restore/route.ts +53 -0
- package/src/modules/notifications/api/batch/route.ts +14 -0
- package/src/modules/notifications/api/feature/route.ts +14 -0
- package/src/modules/notifications/api/mark-all-read/route.ts +34 -0
- package/src/modules/notifications/api/openapi.ts +76 -0
- package/src/modules/notifications/api/role/route.ts +14 -0
- package/src/modules/notifications/api/route.ts +92 -0
- package/src/modules/notifications/api/settings/route.ts +157 -0
- package/src/modules/notifications/api/unread-count/route.ts +38 -0
- package/src/modules/notifications/backend/config/notifications/page.meta.ts +22 -0
- package/src/modules/notifications/backend/config/notifications/page.tsx +12 -0
- package/src/modules/notifications/cli.ts +18 -0
- package/src/modules/notifications/data/entities.ts +99 -0
- package/src/modules/notifications/data/validators.ts +110 -0
- package/src/modules/notifications/di.ts +11 -0
- package/src/modules/notifications/emails/NotificationEmail.tsx +98 -0
- package/src/modules/notifications/frontend/NotificationInboxPageClient.tsx +42 -0
- package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +231 -0
- package/src/modules/notifications/i18n/de.json +50 -0
- package/src/modules/notifications/i18n/en.json +50 -0
- package/src/modules/notifications/i18n/es.json +50 -0
- package/src/modules/notifications/i18n/pl.json +50 -0
- package/src/modules/notifications/index.ts +12 -0
- package/src/modules/notifications/lib/deliveryConfig.ts +145 -0
- package/src/modules/notifications/lib/events.ts +48 -0
- package/src/modules/notifications/lib/notificationBuilder.ts +121 -0
- package/src/modules/notifications/lib/notificationFactory.ts +76 -0
- package/src/modules/notifications/lib/notificationMapper.ts +33 -0
- package/src/modules/notifications/lib/notificationRecipients.ts +83 -0
- package/src/modules/notifications/lib/notificationService.ts +414 -0
- package/src/modules/notifications/lib/routeHelpers.ts +151 -0
- package/src/modules/notifications/lib/safeHref.ts +29 -0
- package/src/modules/notifications/migrations/.snapshot-open-mercato.json +300 -0
- package/src/modules/notifications/migrations/Migration20260123000001.ts +73 -0
- package/src/modules/notifications/migrations/Migration20260126150000.ts +39 -0
- package/src/modules/notifications/subscribers/deliver-notification.ts +175 -0
- package/src/modules/notifications/workers/create-notification.worker.ts +122 -0
- package/src/modules/sales/commands/documents.ts +65 -0
- package/src/modules/sales/commands/payments.ts +33 -0
- package/src/modules/sales/i18n/de.json +20 -0
- package/src/modules/sales/i18n/en.json +25 -1
- package/src/modules/sales/i18n/es.json +20 -0
- package/src/modules/sales/i18n/pl.json +20 -0
- package/src/modules/sales/notifications.client.ts +65 -0
- package/src/modules/sales/notifications.ts +82 -0
- package/src/modules/sales/subscribers/quote-expiring-notification.ts +53 -0
- package/src/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.tsx +156 -0
- package/src/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.tsx +156 -0
- package/src/modules/sales/widgets/notifications/index.ts +2 -0
- package/src/modules/sales/widgets/notifications/useSalesDocumentTotals.ts +81 -0
- package/src/modules/staff/commands/leave-requests.ts +94 -0
- package/src/modules/staff/i18n/de.json +4 -0
- package/src/modules/staff/i18n/en.json +9 -1
- package/src/modules/staff/i18n/es.json +4 -0
- package/src/modules/staff/i18n/pl.json +4 -0
- package/src/modules/staff/notifications.ts +71 -0
- package/src/modules/workflows/i18n/en.json +3 -1
- package/src/modules/workflows/notifications.ts +25 -0
- package/src/modules/workflows/subscribers/task-assigned-notification.ts +53 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/core'
|
|
2
|
+
import type { Knex } from 'knex'
|
|
3
|
+
import { Notification } from '../data/entities'
|
|
4
|
+
import type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'
|
|
5
|
+
import type { NotificationPollData } from '@open-mercato/shared/modules/notifications/types'
|
|
6
|
+
import { NOTIFICATION_EVENTS } from './events'
|
|
7
|
+
import { buildNotificationEntity, emitNotificationCreated, emitNotificationCreatedBatch } from './notificationFactory'
|
|
8
|
+
import { toNotificationDto } from './notificationMapper'
|
|
9
|
+
import { getRecipientUserIdsForFeature, getRecipientUserIdsForRole } from './notificationRecipients'
|
|
10
|
+
|
|
11
|
+
const DEBUG = process.env.NOTIFICATIONS_DEBUG === 'true'
|
|
12
|
+
|
|
13
|
+
function debug(...args: unknown[]): void {
|
|
14
|
+
if (DEBUG) {
|
|
15
|
+
console.log('[notifications]', ...args)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getKnex(em: EntityManager): Knex {
|
|
20
|
+
return (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NotificationServiceContext {
|
|
24
|
+
tenantId: string
|
|
25
|
+
organizationId?: string | null
|
|
26
|
+
userId?: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NotificationService {
|
|
30
|
+
create(input: CreateNotificationInput, ctx: NotificationServiceContext): Promise<Notification>
|
|
31
|
+
createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
|
|
32
|
+
createForRole(input: CreateRoleNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
|
|
33
|
+
createForFeature(input: CreateFeatureNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
|
|
34
|
+
markAsRead(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>
|
|
35
|
+
markAllAsRead(ctx: NotificationServiceContext): Promise<number>
|
|
36
|
+
dismiss(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>
|
|
37
|
+
restoreDismissed(
|
|
38
|
+
notificationId: string,
|
|
39
|
+
status: 'read' | 'unread' | undefined,
|
|
40
|
+
ctx: NotificationServiceContext
|
|
41
|
+
): Promise<Notification>
|
|
42
|
+
executeAction(
|
|
43
|
+
notificationId: string,
|
|
44
|
+
input: ExecuteActionInput,
|
|
45
|
+
ctx: NotificationServiceContext
|
|
46
|
+
): Promise<{ notification: Notification; result: unknown }>
|
|
47
|
+
getUnreadCount(ctx: NotificationServiceContext): Promise<number>
|
|
48
|
+
getPollData(ctx: NotificationServiceContext, since?: string): Promise<NotificationPollData>
|
|
49
|
+
cleanupExpired(): Promise<number>
|
|
50
|
+
deleteBySource(
|
|
51
|
+
sourceEntityType: string,
|
|
52
|
+
sourceEntityId: string,
|
|
53
|
+
ctx: NotificationServiceContext
|
|
54
|
+
): Promise<number>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NotificationServiceDeps {
|
|
58
|
+
em: EntityManager
|
|
59
|
+
eventBus: { emit: (event: string, payload: unknown) => Promise<void> }
|
|
60
|
+
commandBus?: {
|
|
61
|
+
execute: (
|
|
62
|
+
commandId: string,
|
|
63
|
+
options: { input: unknown; ctx: unknown; metadata?: unknown }
|
|
64
|
+
) => Promise<{ result: unknown }>
|
|
65
|
+
}
|
|
66
|
+
container?: { resolve: (name: string) => unknown }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createNotificationService(deps: NotificationServiceDeps): NotificationService {
|
|
70
|
+
const { em: rootEm, eventBus, commandBus, container } = deps
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
async create(input, ctx) {
|
|
74
|
+
const em = rootEm.fork()
|
|
75
|
+
const { recipientUserId, ...content } = input
|
|
76
|
+
const notification = buildNotificationEntity(em, content, recipientUserId, ctx)
|
|
77
|
+
|
|
78
|
+
await em.persistAndFlush(notification)
|
|
79
|
+
|
|
80
|
+
await emitNotificationCreated(eventBus, notification, ctx)
|
|
81
|
+
|
|
82
|
+
return notification
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async createBatch(input, ctx) {
|
|
86
|
+
const em = rootEm.fork()
|
|
87
|
+
const { recipientUserIds, ...content } = input
|
|
88
|
+
const notifications: Notification[] = []
|
|
89
|
+
|
|
90
|
+
for (const recipientUserId of recipientUserIds) {
|
|
91
|
+
const notification = buildNotificationEntity(em, content, recipientUserId, ctx)
|
|
92
|
+
notifications.push(notification)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await em.persistAndFlush(notifications)
|
|
96
|
+
|
|
97
|
+
await emitNotificationCreatedBatch(eventBus, notifications, ctx)
|
|
98
|
+
|
|
99
|
+
return notifications
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async createForRole(input, ctx) {
|
|
103
|
+
const em = rootEm.fork()
|
|
104
|
+
|
|
105
|
+
const knex = getKnex(em)
|
|
106
|
+
const recipientUserIds = await getRecipientUserIdsForRole(knex, ctx.tenantId, input.roleId)
|
|
107
|
+
if (recipientUserIds.length === 0) {
|
|
108
|
+
return []
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { roleId: _roleId, ...content } = input
|
|
112
|
+
const notifications: Notification[] = []
|
|
113
|
+
|
|
114
|
+
for (const recipientUserId of recipientUserIds) {
|
|
115
|
+
const notification = buildNotificationEntity(em, content, recipientUserId, ctx)
|
|
116
|
+
notifications.push(notification)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await em.persistAndFlush(notifications)
|
|
120
|
+
|
|
121
|
+
await emitNotificationCreatedBatch(eventBus, notifications, ctx)
|
|
122
|
+
|
|
123
|
+
return notifications
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async createForFeature(input, ctx) {
|
|
127
|
+
const em = rootEm.fork()
|
|
128
|
+
const knex = getKnex(em)
|
|
129
|
+
const recipientUserIds = await getRecipientUserIdsForFeature(knex, ctx.tenantId, input.requiredFeature)
|
|
130
|
+
|
|
131
|
+
if (recipientUserIds.length === 0) {
|
|
132
|
+
debug('No users found with feature:', input.requiredFeature, 'in tenant:', ctx.tenantId)
|
|
133
|
+
return []
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
debug('Creating notifications for', recipientUserIds.length, 'user(s) with feature:', input.requiredFeature)
|
|
137
|
+
|
|
138
|
+
const { requiredFeature: _requiredFeature, ...content } = input
|
|
139
|
+
const notifications: Notification[] = []
|
|
140
|
+
|
|
141
|
+
for (const recipientUserId of recipientUserIds) {
|
|
142
|
+
const notification = buildNotificationEntity(em, content, recipientUserId, ctx)
|
|
143
|
+
notifications.push(notification)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await em.persistAndFlush(notifications)
|
|
147
|
+
|
|
148
|
+
await emitNotificationCreatedBatch(eventBus, notifications, ctx)
|
|
149
|
+
|
|
150
|
+
return notifications
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async markAsRead(notificationId, ctx) {
|
|
154
|
+
const em = rootEm.fork()
|
|
155
|
+
const notification = await em.findOneOrFail(Notification, {
|
|
156
|
+
id: notificationId,
|
|
157
|
+
recipientUserId: ctx.userId,
|
|
158
|
+
tenantId: ctx.tenantId,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
if (notification.status === 'unread') {
|
|
162
|
+
notification.status = 'read'
|
|
163
|
+
notification.readAt = new Date()
|
|
164
|
+
await em.flush()
|
|
165
|
+
|
|
166
|
+
await eventBus.emit(NOTIFICATION_EVENTS.READ, {
|
|
167
|
+
notificationId: notification.id,
|
|
168
|
+
userId: ctx.userId,
|
|
169
|
+
tenantId: ctx.tenantId,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return notification
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async markAllAsRead(ctx) {
|
|
177
|
+
const em = rootEm.fork()
|
|
178
|
+
const knex = getKnex(em)
|
|
179
|
+
|
|
180
|
+
const result = await knex('notifications')
|
|
181
|
+
.where({
|
|
182
|
+
recipient_user_id: ctx.userId,
|
|
183
|
+
tenant_id: ctx.tenantId,
|
|
184
|
+
status: 'unread',
|
|
185
|
+
})
|
|
186
|
+
.update({
|
|
187
|
+
status: 'read',
|
|
188
|
+
read_at: knex.fn.now(),
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async dismiss(notificationId, ctx) {
|
|
195
|
+
const em = rootEm.fork()
|
|
196
|
+
const notification = await em.findOneOrFail(Notification, {
|
|
197
|
+
id: notificationId,
|
|
198
|
+
recipientUserId: ctx.userId,
|
|
199
|
+
tenantId: ctx.tenantId,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
notification.status = 'dismissed'
|
|
203
|
+
notification.dismissedAt = new Date()
|
|
204
|
+
await em.flush()
|
|
205
|
+
|
|
206
|
+
await eventBus.emit(NOTIFICATION_EVENTS.DISMISSED, {
|
|
207
|
+
notificationId: notification.id,
|
|
208
|
+
userId: ctx.userId,
|
|
209
|
+
tenantId: ctx.tenantId,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return notification
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async restoreDismissed(notificationId, status, ctx) {
|
|
216
|
+
const em = rootEm.fork()
|
|
217
|
+
const notification = await em.findOneOrFail(Notification, {
|
|
218
|
+
id: notificationId,
|
|
219
|
+
recipientUserId: ctx.userId,
|
|
220
|
+
tenantId: ctx.tenantId,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if (notification.status !== 'dismissed') {
|
|
224
|
+
return notification
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const targetStatus = status ?? 'read'
|
|
228
|
+
notification.status = targetStatus
|
|
229
|
+
notification.dismissedAt = null
|
|
230
|
+
|
|
231
|
+
if (targetStatus === 'unread') {
|
|
232
|
+
notification.readAt = null
|
|
233
|
+
} else if (!notification.readAt) {
|
|
234
|
+
notification.readAt = new Date()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await em.flush()
|
|
238
|
+
|
|
239
|
+
await eventBus.emit(NOTIFICATION_EVENTS.RESTORED, {
|
|
240
|
+
notificationId: notification.id,
|
|
241
|
+
userId: ctx.userId,
|
|
242
|
+
tenantId: ctx.tenantId,
|
|
243
|
+
status: targetStatus,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return notification
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async executeAction(notificationId, input, ctx) {
|
|
250
|
+
const em = rootEm.fork()
|
|
251
|
+
const notification = await em.findOneOrFail(Notification, {
|
|
252
|
+
id: notificationId,
|
|
253
|
+
recipientUserId: ctx.userId,
|
|
254
|
+
tenantId: ctx.tenantId,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const actionData = notification.actionData
|
|
258
|
+
const action = actionData?.actions?.find((a) => a.id === input.actionId)
|
|
259
|
+
|
|
260
|
+
if (!action) {
|
|
261
|
+
throw new Error('Action not found')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let result: unknown = null
|
|
265
|
+
|
|
266
|
+
if (action.commandId && commandBus && container) {
|
|
267
|
+
const commandInput = {
|
|
268
|
+
id: notification.sourceEntityId,
|
|
269
|
+
...input.payload,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Build a CommandRuntimeContext from the notification service context
|
|
273
|
+
const commandCtx = {
|
|
274
|
+
container,
|
|
275
|
+
auth: {
|
|
276
|
+
sub: ctx.userId,
|
|
277
|
+
tenantId: ctx.tenantId,
|
|
278
|
+
orgId: ctx.organizationId,
|
|
279
|
+
},
|
|
280
|
+
organizationScope: null,
|
|
281
|
+
selectedOrganizationId: ctx.organizationId ?? null,
|
|
282
|
+
organizationIds: ctx.organizationId ? [ctx.organizationId] : null,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const commandResult = await commandBus.execute(action.commandId, {
|
|
286
|
+
input: commandInput,
|
|
287
|
+
ctx: commandCtx,
|
|
288
|
+
metadata: {
|
|
289
|
+
tenantId: ctx.tenantId,
|
|
290
|
+
organizationId: ctx.organizationId,
|
|
291
|
+
resourceKind: 'notifications',
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
result = commandResult.result
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
notification.status = 'actioned'
|
|
299
|
+
notification.actionedAt = new Date()
|
|
300
|
+
notification.actionTaken = input.actionId
|
|
301
|
+
notification.actionResult = result as Record<string, unknown>
|
|
302
|
+
|
|
303
|
+
if (!notification.readAt) {
|
|
304
|
+
notification.readAt = new Date()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await em.flush()
|
|
308
|
+
|
|
309
|
+
await eventBus.emit(NOTIFICATION_EVENTS.ACTIONED, {
|
|
310
|
+
notificationId: notification.id,
|
|
311
|
+
actionId: input.actionId,
|
|
312
|
+
userId: ctx.userId,
|
|
313
|
+
tenantId: ctx.tenantId,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
return { notification, result }
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
async getUnreadCount(ctx) {
|
|
320
|
+
const em = rootEm.fork()
|
|
321
|
+
return em.count(Notification, {
|
|
322
|
+
recipientUserId: ctx.userId,
|
|
323
|
+
tenantId: ctx.tenantId,
|
|
324
|
+
status: 'unread',
|
|
325
|
+
})
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
async getPollData(ctx, since) {
|
|
329
|
+
const em = rootEm.fork()
|
|
330
|
+
const filters: Record<string, unknown> = {
|
|
331
|
+
recipientUserId: ctx.userId,
|
|
332
|
+
tenantId: ctx.tenantId,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (since) {
|
|
336
|
+
filters.createdAt = { $gt: new Date(since) }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const [notifications, unreadCount] = await Promise.all([
|
|
340
|
+
em.find(Notification, filters, {
|
|
341
|
+
orderBy: { createdAt: 'desc' },
|
|
342
|
+
limit: 50,
|
|
343
|
+
}),
|
|
344
|
+
em.count(Notification, {
|
|
345
|
+
recipientUserId: ctx.userId,
|
|
346
|
+
tenantId: ctx.tenantId,
|
|
347
|
+
status: 'unread',
|
|
348
|
+
}),
|
|
349
|
+
])
|
|
350
|
+
|
|
351
|
+
const recent = notifications.map(toNotificationDto)
|
|
352
|
+
const hasNew = since ? recent.length > 0 : false
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
unreadCount,
|
|
356
|
+
recent,
|
|
357
|
+
hasNew,
|
|
358
|
+
lastId: recent[0]?.id,
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async cleanupExpired() {
|
|
363
|
+
const em = rootEm.fork()
|
|
364
|
+
const knex = getKnex(em)
|
|
365
|
+
|
|
366
|
+
const result = await knex('notifications')
|
|
367
|
+
.where('expires_at', '<', knex.fn.now())
|
|
368
|
+
.whereNotIn('status', ['actioned', 'dismissed'])
|
|
369
|
+
.update({
|
|
370
|
+
status: 'dismissed',
|
|
371
|
+
dismissed_at: knex.fn.now(),
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
return result
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
async deleteBySource(sourceEntityType, sourceEntityId, ctx) {
|
|
378
|
+
const em = rootEm.fork()
|
|
379
|
+
const knex = getKnex(em)
|
|
380
|
+
|
|
381
|
+
const result = await knex('notifications')
|
|
382
|
+
.where({
|
|
383
|
+
source_entity_type: sourceEntityType,
|
|
384
|
+
source_entity_id: sourceEntityId,
|
|
385
|
+
tenant_id: ctx.tenantId,
|
|
386
|
+
})
|
|
387
|
+
.delete()
|
|
388
|
+
|
|
389
|
+
return result
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Helper to create notification service from a DI container.
|
|
396
|
+
* Use this in API routes and commands to avoid DI resolution issues.
|
|
397
|
+
*/
|
|
398
|
+
export function resolveNotificationService(container: {
|
|
399
|
+
resolve: (name: string) => unknown
|
|
400
|
+
}): NotificationService {
|
|
401
|
+
const em = container.resolve('em') as EntityManager
|
|
402
|
+
const eventBus = container.resolve('eventBus') as { emit: (event: string, payload: unknown) => Promise<void> }
|
|
403
|
+
|
|
404
|
+
// commandBus may not be registered in all contexts, so resolve it safely
|
|
405
|
+
let commandBus: NotificationServiceDeps['commandBus']
|
|
406
|
+
try {
|
|
407
|
+
commandBus = container.resolve('commandBus') as typeof commandBus
|
|
408
|
+
} catch {
|
|
409
|
+
// commandBus not available - actions with commandId won't work
|
|
410
|
+
commandBus = undefined
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return createNotificationService({ em, eventBus, commandBus, container })
|
|
414
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
|
|
3
|
+
import { resolveNotificationService, type NotificationService } from './notificationService'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notification scope context for service calls
|
|
7
|
+
*/
|
|
8
|
+
export interface NotificationScope {
|
|
9
|
+
tenantId: string
|
|
10
|
+
organizationId: string | null
|
|
11
|
+
userId: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolved notification context from a request
|
|
16
|
+
*/
|
|
17
|
+
export interface NotificationRequestContext {
|
|
18
|
+
service: NotificationService
|
|
19
|
+
scope: NotificationScope
|
|
20
|
+
ctx: Awaited<ReturnType<typeof resolveRequestContext>>['ctx']
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve notification service and scope from a request.
|
|
25
|
+
* Centralizes the common pattern used across all notification API routes.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveNotificationContext(req: Request): Promise<NotificationRequestContext> {
|
|
28
|
+
const { ctx } = await resolveRequestContext(req)
|
|
29
|
+
return {
|
|
30
|
+
service: resolveNotificationService(ctx.container),
|
|
31
|
+
scope: {
|
|
32
|
+
tenantId: ctx.auth?.tenantId ?? '',
|
|
33
|
+
organizationId: ctx.selectedOrganizationId ?? null,
|
|
34
|
+
userId: ctx.auth?.sub ?? null,
|
|
35
|
+
},
|
|
36
|
+
ctx,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a POST handler for bulk notification creation routes.
|
|
42
|
+
* Used by batch, role, and feature notification endpoints.
|
|
43
|
+
*/
|
|
44
|
+
export function createBulkNotificationRoute<TSchema extends z.ZodTypeAny>(
|
|
45
|
+
schema: TSchema,
|
|
46
|
+
serviceMethod: 'createBatch' | 'createForRole' | 'createForFeature'
|
|
47
|
+
) {
|
|
48
|
+
return async function POST(req: Request) {
|
|
49
|
+
const { service, scope } = await resolveNotificationContext(req)
|
|
50
|
+
|
|
51
|
+
const body = await req.json().catch(() => ({}))
|
|
52
|
+
const input = schema.parse(body)
|
|
53
|
+
|
|
54
|
+
const notifications = await service[serviceMethod](input as never, scope)
|
|
55
|
+
|
|
56
|
+
return Response.json({
|
|
57
|
+
ok: true,
|
|
58
|
+
count: notifications.length,
|
|
59
|
+
ids: notifications.map((n) => n.id),
|
|
60
|
+
}, { status: 201 })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create OpenAPI spec for bulk notification creation routes.
|
|
66
|
+
*/
|
|
67
|
+
export function createBulkNotificationOpenApi<TSchema extends z.ZodTypeAny>(
|
|
68
|
+
schema: TSchema,
|
|
69
|
+
summary: string,
|
|
70
|
+
description?: string
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
POST: {
|
|
74
|
+
summary,
|
|
75
|
+
description,
|
|
76
|
+
tags: ['Notifications'],
|
|
77
|
+
requestBody: {
|
|
78
|
+
required: true,
|
|
79
|
+
content: {
|
|
80
|
+
'application/json': {
|
|
81
|
+
schema,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
responses: {
|
|
86
|
+
201: {
|
|
87
|
+
description: 'Notifications created',
|
|
88
|
+
content: {
|
|
89
|
+
'application/json': {
|
|
90
|
+
schema: z.object({
|
|
91
|
+
ok: z.boolean(),
|
|
92
|
+
count: z.number(),
|
|
93
|
+
ids: z.array(z.string().uuid()),
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a PUT handler for single notification action routes.
|
|
105
|
+
* Used by read and dismiss endpoints.
|
|
106
|
+
*/
|
|
107
|
+
export function createSingleNotificationActionRoute(
|
|
108
|
+
serviceMethod: 'markAsRead' | 'dismiss'
|
|
109
|
+
) {
|
|
110
|
+
return async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
111
|
+
const { id } = await params
|
|
112
|
+
const { service, scope } = await resolveNotificationContext(req)
|
|
113
|
+
|
|
114
|
+
await service[serviceMethod](id, scope)
|
|
115
|
+
|
|
116
|
+
return Response.json({ ok: true })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create OpenAPI spec for single notification action routes.
|
|
122
|
+
*/
|
|
123
|
+
export function createSingleNotificationActionOpenApi(
|
|
124
|
+
summary: string,
|
|
125
|
+
description: string
|
|
126
|
+
) {
|
|
127
|
+
return {
|
|
128
|
+
PUT: {
|
|
129
|
+
summary,
|
|
130
|
+
tags: ['Notifications'],
|
|
131
|
+
parameters: [
|
|
132
|
+
{
|
|
133
|
+
name: 'id',
|
|
134
|
+
in: 'path',
|
|
135
|
+
required: true,
|
|
136
|
+
schema: { type: 'string', format: 'uuid' },
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
responses: {
|
|
140
|
+
200: {
|
|
141
|
+
description,
|
|
142
|
+
content: {
|
|
143
|
+
'application/json': {
|
|
144
|
+
schema: z.object({ ok: z.boolean() }),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NotificationAction } from '@open-mercato/shared/modules/notifications/types'
|
|
2
|
+
|
|
3
|
+
export function isSafeNotificationHref(href: string): boolean {
|
|
4
|
+
return href.startsWith('/') && !href.startsWith('//')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function assertSafeNotificationHref(href: string | undefined | null): string | undefined {
|
|
8
|
+
if (href == null) {
|
|
9
|
+
return undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!isSafeNotificationHref(href)) {
|
|
13
|
+
throw new Error('Notification href must be a same-origin relative path starting with /')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return href
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sanitizeNotificationActions(
|
|
20
|
+
actions: NotificationAction[] | undefined
|
|
21
|
+
): NotificationAction[] | undefined {
|
|
22
|
+
if (!actions) {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return actions.map((action) => (
|
|
27
|
+
action.href ? { ...action, href: assertSafeNotificationHref(action.href) } : action
|
|
28
|
+
))
|
|
29
|
+
}
|