@open-mercato/core 0.4.2-canary-7c76659938 → 0.4.2-canary-70c8402224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/dist/generated/entities/notification/index.js +57 -0
  2. package/dist/generated/entities/notification/index.js.map +7 -0
  3. package/dist/generated/entities.ids.generated.js +5 -1
  4. package/dist/generated/entities.ids.generated.js.map +2 -2
  5. package/dist/generated/entity-fields-registry.js +2 -0
  6. package/dist/generated/entity-fields-registry.js.map +2 -2
  7. package/dist/modules/api_docs/frontend/docs/api/page.js +3 -2
  8. package/dist/modules/api_docs/frontend/docs/api/page.js.map +2 -2
  9. package/dist/modules/auth/api/admin/nav.js +4 -3
  10. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  11. package/dist/modules/auth/api/profile/route.js +155 -0
  12. package/dist/modules/auth/api/profile/route.js.map +7 -0
  13. package/dist/modules/auth/api/reset/confirm.js +25 -2
  14. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  15. package/dist/modules/auth/api/reset.js +23 -0
  16. package/dist/modules/auth/api/reset.js.map +2 -2
  17. package/dist/modules/auth/api/sidebar/preferences/route.js +14 -9
  18. package/dist/modules/auth/api/sidebar/preferences/route.js.map +2 -2
  19. package/dist/modules/auth/backend/auth/profile/page.js +99 -0
  20. package/dist/modules/auth/backend/auth/profile/page.js.map +7 -0
  21. package/dist/modules/auth/backend/auth/profile/page.meta.js +12 -0
  22. package/dist/modules/auth/backend/auth/profile/page.meta.js.map +7 -0
  23. package/dist/modules/auth/commands/users.js +55 -0
  24. package/dist/modules/auth/commands/users.js.map +2 -2
  25. package/dist/modules/auth/lib/setup-app.js +1 -0
  26. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  27. package/dist/modules/auth/notifications.js +112 -0
  28. package/dist/modules/auth/notifications.js.map +7 -0
  29. package/dist/modules/auth/services/authService.js +3 -3
  30. package/dist/modules/auth/services/authService.js.map +2 -2
  31. package/dist/modules/business_rules/notifications.js +28 -0
  32. package/dist/modules/business_rules/notifications.js.map +7 -0
  33. package/dist/modules/business_rules/subscribers/rule-execution-failed-notification.js +37 -0
  34. package/dist/modules/business_rules/subscribers/rule-execution-failed-notification.js.map +7 -0
  35. package/dist/modules/catalog/notifications.js +28 -0
  36. package/dist/modules/catalog/notifications.js.map +7 -0
  37. package/dist/modules/catalog/subscribers/low-stock-notification.js +38 -0
  38. package/dist/modules/catalog/subscribers/low-stock-notification.js.map +7 -0
  39. package/dist/modules/configs/cli.js +6 -0
  40. package/dist/modules/configs/cli.js.map +2 -2
  41. package/dist/modules/customers/commands/deals.js +31 -0
  42. package/dist/modules/customers/commands/deals.js.map +2 -2
  43. package/dist/modules/customers/notifications.js +48 -0
  44. package/dist/modules/customers/notifications.js.map +7 -0
  45. package/dist/modules/notifications/acl.js +11 -0
  46. package/dist/modules/notifications/acl.js.map +7 -0
  47. package/dist/modules/notifications/api/[id]/action/route.js +69 -0
  48. package/dist/modules/notifications/api/[id]/action/route.js.map +7 -0
  49. package/dist/modules/notifications/api/[id]/dismiss/route.js +15 -0
  50. package/dist/modules/notifications/api/[id]/dismiss/route.js.map +7 -0
  51. package/dist/modules/notifications/api/[id]/read/route.js +15 -0
  52. package/dist/modules/notifications/api/[id]/read/route.js.map +7 -0
  53. package/dist/modules/notifications/api/[id]/restore/route.js +53 -0
  54. package/dist/modules/notifications/api/[id]/restore/route.js.map +7 -0
  55. package/dist/modules/notifications/api/batch/route.js +17 -0
  56. package/dist/modules/notifications/api/batch/route.js.map +7 -0
  57. package/dist/modules/notifications/api/feature/route.js +17 -0
  58. package/dist/modules/notifications/api/feature/route.js.map +7 -0
  59. package/dist/modules/notifications/api/mark-all-read/route.js +35 -0
  60. package/dist/modules/notifications/api/mark-all-read/route.js.map +7 -0
  61. package/dist/modules/notifications/api/openapi.js +57 -0
  62. package/dist/modules/notifications/api/openapi.js.map +7 -0
  63. package/dist/modules/notifications/api/role/route.js +17 -0
  64. package/dist/modules/notifications/api/role/route.js.map +7 -0
  65. package/dist/modules/notifications/api/route.js +85 -0
  66. package/dist/modules/notifications/api/route.js.map +7 -0
  67. package/dist/modules/notifications/api/settings/route.js +96 -0
  68. package/dist/modules/notifications/api/settings/route.js.map +7 -0
  69. package/dist/modules/notifications/api/unread-count/route.js +38 -0
  70. package/dist/modules/notifications/api/unread-count/route.js.map +7 -0
  71. package/dist/modules/notifications/backend/config/notifications/page.js +10 -0
  72. package/dist/modules/notifications/backend/config/notifications/page.js.map +7 -0
  73. package/dist/modules/notifications/backend/config/notifications/page.meta.js +24 -0
  74. package/dist/modules/notifications/backend/config/notifications/page.meta.js.map +7 -0
  75. package/dist/modules/notifications/cli.js +16 -0
  76. package/dist/modules/notifications/cli.js.map +7 -0
  77. package/dist/modules/notifications/data/entities.js +112 -0
  78. package/dist/modules/notifications/data/entities.js.map +7 -0
  79. package/dist/modules/notifications/data/validators.js +94 -0
  80. package/dist/modules/notifications/data/validators.js.map +7 -0
  81. package/dist/modules/notifications/di.js +13 -0
  82. package/dist/modules/notifications/di.js.map +7 -0
  83. package/dist/modules/notifications/emails/NotificationEmail.js +58 -0
  84. package/dist/modules/notifications/emails/NotificationEmail.js.map +7 -0
  85. package/dist/modules/notifications/frontend/NotificationInboxPageClient.js +44 -0
  86. package/dist/modules/notifications/frontend/NotificationInboxPageClient.js.map +7 -0
  87. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +219 -0
  88. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +7 -0
  89. package/dist/modules/notifications/index.js +14 -0
  90. package/dist/modules/notifications/index.js.map +7 -0
  91. package/dist/modules/notifications/lib/deliveryConfig.js +105 -0
  92. package/dist/modules/notifications/lib/deliveryConfig.js.map +7 -0
  93. package/dist/modules/notifications/lib/events.js +12 -0
  94. package/dist/modules/notifications/lib/events.js.map +7 -0
  95. package/dist/modules/notifications/lib/notificationBuilder.js +66 -0
  96. package/dist/modules/notifications/lib/notificationBuilder.js.map +7 -0
  97. package/dist/modules/notifications/lib/notificationFactory.js +54 -0
  98. package/dist/modules/notifications/lib/notificationFactory.js.map +7 -0
  99. package/dist/modules/notifications/lib/notificationMapper.js +34 -0
  100. package/dist/modules/notifications/lib/notificationMapper.js.map +7 -0
  101. package/dist/modules/notifications/lib/notificationRecipients.js +35 -0
  102. package/dist/modules/notifications/lib/notificationRecipients.js.map +7 -0
  103. package/dist/modules/notifications/lib/notificationService.js +279 -0
  104. package/dist/modules/notifications/lib/notificationService.js.map +7 -0
  105. package/dist/modules/notifications/lib/routeHelpers.js +101 -0
  106. package/dist/modules/notifications/lib/routeHelpers.js.map +7 -0
  107. package/dist/modules/notifications/lib/safeHref.js +24 -0
  108. package/dist/modules/notifications/lib/safeHref.js.map +7 -0
  109. package/dist/modules/notifications/migrations/Migration20260123000001.js +70 -0
  110. package/dist/modules/notifications/migrations/Migration20260123000001.js.map +7 -0
  111. package/dist/modules/notifications/migrations/Migration20260126150000.js +37 -0
  112. package/dist/modules/notifications/migrations/Migration20260126150000.js.map +7 -0
  113. package/dist/modules/notifications/subscribers/deliver-notification.js +139 -0
  114. package/dist/modules/notifications/subscribers/deliver-notification.js.map +7 -0
  115. package/dist/modules/notifications/workers/create-notification.worker.js +70 -0
  116. package/dist/modules/notifications/workers/create-notification.worker.js.map +7 -0
  117. package/dist/modules/sales/commands/documents.js +53 -0
  118. package/dist/modules/sales/commands/documents.js.map +2 -2
  119. package/dist/modules/sales/commands/payments.js +26 -0
  120. package/dist/modules/sales/commands/payments.js.map +2 -2
  121. package/dist/modules/sales/notifications.client.js +51 -0
  122. package/dist/modules/sales/notifications.client.js.map +7 -0
  123. package/dist/modules/sales/notifications.js +88 -0
  124. package/dist/modules/sales/notifications.js.map +7 -0
  125. package/dist/modules/sales/subscribers/quote-expiring-notification.js +38 -0
  126. package/dist/modules/sales/subscribers/quote-expiring-notification.js.map +7 -0
  127. package/dist/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.js +137 -0
  128. package/dist/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.js.map +7 -0
  129. package/dist/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.js +137 -0
  130. package/dist/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.js.map +7 -0
  131. package/dist/modules/sales/widgets/notifications/index.js +7 -0
  132. package/dist/modules/sales/widgets/notifications/index.js.map +7 -0
  133. package/dist/modules/sales/widgets/notifications/useSalesDocumentTotals.js +60 -0
  134. package/dist/modules/sales/widgets/notifications/useSalesDocumentTotals.js.map +7 -0
  135. package/dist/modules/staff/commands/leave-requests.js +79 -0
  136. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  137. package/dist/modules/staff/notifications.js +75 -0
  138. package/dist/modules/staff/notifications.js.map +7 -0
  139. package/dist/modules/workflows/notifications.js +28 -0
  140. package/dist/modules/workflows/notifications.js.map +7 -0
  141. package/dist/modules/workflows/subscribers/task-assigned-notification.js +38 -0
  142. package/dist/modules/workflows/subscribers/task-assigned-notification.js.map +7 -0
  143. package/generated/entities/notification/index.ts +27 -0
  144. package/generated/entities.ids.generated.ts +5 -1
  145. package/generated/entity-fields-registry.ts +2 -0
  146. package/package.json +2 -2
  147. package/src/modules/api_docs/frontend/docs/api/page.tsx +3 -2
  148. package/src/modules/auth/api/admin/nav.ts +10 -6
  149. package/src/modules/auth/api/profile/route.ts +160 -0
  150. package/src/modules/auth/api/reset/confirm.ts +25 -2
  151. package/src/modules/auth/api/reset.ts +23 -0
  152. package/src/modules/auth/api/sidebar/preferences/route.ts +21 -12
  153. package/src/modules/auth/backend/auth/profile/page.meta.ts +8 -0
  154. package/src/modules/auth/backend/auth/profile/page.tsx +127 -0
  155. package/src/modules/auth/commands/users.ts +68 -0
  156. package/src/modules/auth/i18n/de.json +29 -1
  157. package/src/modules/auth/i18n/en.json +29 -1
  158. package/src/modules/auth/i18n/es.json +29 -1
  159. package/src/modules/auth/i18n/pl.json +29 -1
  160. package/src/modules/auth/lib/setup-app.ts +1 -0
  161. package/src/modules/auth/notifications.ts +109 -0
  162. package/src/modules/auth/services/authService.ts +4 -4
  163. package/src/modules/business_rules/i18n/en.json +3 -1
  164. package/src/modules/business_rules/notifications.ts +25 -0
  165. package/src/modules/business_rules/subscribers/rule-execution-failed-notification.ts +50 -0
  166. package/src/modules/catalog/i18n/en.json +3 -1
  167. package/src/modules/catalog/notifications.ts +25 -0
  168. package/src/modules/catalog/subscribers/low-stock-notification.ts +52 -0
  169. package/src/modules/configs/cli.ts +6 -0
  170. package/src/modules/customers/commands/deals.ts +39 -0
  171. package/src/modules/customers/i18n/en.json +5 -1
  172. package/src/modules/customers/notifications.ts +44 -0
  173. package/src/modules/notifications/acl.ts +7 -0
  174. package/src/modules/notifications/api/[id]/action/route.ts +70 -0
  175. package/src/modules/notifications/api/[id]/dismiss/route.ts +12 -0
  176. package/src/modules/notifications/api/[id]/read/route.ts +12 -0
  177. package/src/modules/notifications/api/[id]/restore/route.ts +53 -0
  178. package/src/modules/notifications/api/batch/route.ts +14 -0
  179. package/src/modules/notifications/api/feature/route.ts +14 -0
  180. package/src/modules/notifications/api/mark-all-read/route.ts +34 -0
  181. package/src/modules/notifications/api/openapi.ts +52 -0
  182. package/src/modules/notifications/api/role/route.ts +14 -0
  183. package/src/modules/notifications/api/route.ts +92 -0
  184. package/src/modules/notifications/api/settings/route.ts +98 -0
  185. package/src/modules/notifications/api/unread-count/route.ts +38 -0
  186. package/src/modules/notifications/backend/config/notifications/page.meta.ts +22 -0
  187. package/src/modules/notifications/backend/config/notifications/page.tsx +12 -0
  188. package/src/modules/notifications/cli.ts +18 -0
  189. package/src/modules/notifications/data/entities.ts +99 -0
  190. package/src/modules/notifications/data/validators.ts +110 -0
  191. package/src/modules/notifications/di.ts +11 -0
  192. package/src/modules/notifications/emails/NotificationEmail.tsx +98 -0
  193. package/src/modules/notifications/frontend/NotificationInboxPageClient.tsx +42 -0
  194. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +231 -0
  195. package/src/modules/notifications/i18n/de.json +50 -0
  196. package/src/modules/notifications/i18n/en.json +50 -0
  197. package/src/modules/notifications/i18n/es.json +50 -0
  198. package/src/modules/notifications/i18n/pl.json +50 -0
  199. package/src/modules/notifications/index.ts +12 -0
  200. package/src/modules/notifications/lib/deliveryConfig.ts +145 -0
  201. package/src/modules/notifications/lib/events.ts +48 -0
  202. package/src/modules/notifications/lib/notificationBuilder.ts +121 -0
  203. package/src/modules/notifications/lib/notificationFactory.ts +76 -0
  204. package/src/modules/notifications/lib/notificationMapper.ts +33 -0
  205. package/src/modules/notifications/lib/notificationRecipients.ts +83 -0
  206. package/src/modules/notifications/lib/notificationService.ts +414 -0
  207. package/src/modules/notifications/lib/routeHelpers.ts +151 -0
  208. package/src/modules/notifications/lib/safeHref.ts +29 -0
  209. package/src/modules/notifications/migrations/.snapshot-open-mercato.json +300 -0
  210. package/src/modules/notifications/migrations/Migration20260123000001.ts +73 -0
  211. package/src/modules/notifications/migrations/Migration20260126150000.ts +39 -0
  212. package/src/modules/notifications/subscribers/deliver-notification.ts +175 -0
  213. package/src/modules/notifications/workers/create-notification.worker.ts +122 -0
  214. package/src/modules/sales/commands/documents.ts +65 -0
  215. package/src/modules/sales/commands/payments.ts +33 -0
  216. package/src/modules/sales/i18n/de.json +20 -0
  217. package/src/modules/sales/i18n/en.json +25 -1
  218. package/src/modules/sales/i18n/es.json +20 -0
  219. package/src/modules/sales/i18n/pl.json +20 -0
  220. package/src/modules/sales/notifications.client.ts +65 -0
  221. package/src/modules/sales/notifications.ts +82 -0
  222. package/src/modules/sales/subscribers/quote-expiring-notification.ts +53 -0
  223. package/src/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.tsx +156 -0
  224. package/src/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.tsx +156 -0
  225. package/src/modules/sales/widgets/notifications/index.ts +2 -0
  226. package/src/modules/sales/widgets/notifications/useSalesDocumentTotals.ts +81 -0
  227. package/src/modules/staff/commands/leave-requests.ts +94 -0
  228. package/src/modules/staff/i18n/de.json +4 -0
  229. package/src/modules/staff/i18n/en.json +9 -1
  230. package/src/modules/staff/i18n/es.json +4 -0
  231. package/src/modules/staff/i18n/pl.json +4 -0
  232. package/src/modules/staff/notifications.ts +71 -0
  233. package/src/modules/workflows/i18n/en.json +3 -1
  234. package/src/modules/workflows/notifications.ts +25 -0
  235. 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
+ }