@open-mercato/core 0.6.8-develop.6983.1.e4f794099e → 0.6.8-develop.6985.1.fb93574faa

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 (597) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/notification/index.js +6 -0
  3. package/dist/generated/entities/notification/index.js.map +2 -2
  4. package/dist/generated/entities/notification_preference/index.js +19 -0
  5. package/dist/generated/entities/notification_preference/index.js.map +7 -0
  6. package/dist/generated/entities/notification_type/index.js +21 -0
  7. package/dist/generated/entities/notification_type/index.js.map +7 -0
  8. package/dist/generated/entities/notification_type_override/index.js +17 -0
  9. package/dist/generated/entities/notification_type_override/index.js.map +7 -0
  10. package/dist/generated/entities/push_notification_delivery/index.js +41 -0
  11. package/dist/generated/entities/push_notification_delivery/index.js.map +7 -0
  12. package/dist/generated/entities/user_device/index.js +35 -0
  13. package/dist/generated/entities/user_device/index.js.map +7 -0
  14. package/dist/generated/entities.ids.generated.js +12 -1
  15. package/dist/generated/entities.ids.generated.js.map +2 -2
  16. package/dist/generated/entity-fields-registry.js +72 -0
  17. package/dist/generated/entity-fields-registry.js.map +2 -2
  18. package/dist/helpers/integration/api.js +2 -1
  19. package/dist/helpers/integration/api.js.map +2 -2
  20. package/dist/helpers/integration/appRoot.js +9 -0
  21. package/dist/helpers/integration/appRoot.js.map +7 -0
  22. package/dist/helpers/integration/dbFixtures.js +1 -4
  23. package/dist/helpers/integration/dbFixtures.js.map +2 -2
  24. package/dist/helpers/integration/pushFake.js +126 -0
  25. package/dist/helpers/integration/pushFake.js.map +7 -0
  26. package/dist/modules/auth/notifications.js +16 -0
  27. package/dist/modules/auth/notifications.js.map +2 -2
  28. package/dist/modules/business_rules/notifications.js +2 -0
  29. package/dist/modules/business_rules/notifications.js.map +2 -2
  30. package/dist/modules/catalog/notifications.js +2 -0
  31. package/dist/modules/catalog/notifications.js.map +2 -2
  32. package/dist/modules/communication_channels/acl.js +9 -0
  33. package/dist/modules/communication_channels/acl.js.map +2 -2
  34. package/dist/modules/communication_channels/api/delete/admin/channels/[id]/route.js +133 -0
  35. package/dist/modules/communication_channels/api/delete/admin/channels/[id]/route.js.map +7 -0
  36. package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js +6 -2
  37. package/dist/modules/communication_channels/api/get/channels/[id]/health/route.js.map +2 -2
  38. package/dist/modules/communication_channels/api/get/channels/[id]/route.js +6 -2
  39. package/dist/modules/communication_channels/api/get/channels/[id]/route.js.map +2 -2
  40. package/dist/modules/communication_channels/api/get/channels/route.js +2 -1
  41. package/dist/modules/communication_channels/api/get/channels/route.js.map +2 -2
  42. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +10 -5
  43. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  44. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +29 -1
  45. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  46. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +157 -0
  47. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +7 -0
  48. package/dist/modules/communication_channels/backend/communication_channels/channels/page.js +8 -1
  49. package/dist/modules/communication_channels/backend/communication_channels/channels/page.js.map +2 -2
  50. package/dist/modules/communication_channels/commands/admin-delete-channel.js +141 -0
  51. package/dist/modules/communication_channels/commands/admin-delete-channel.js.map +7 -0
  52. package/dist/modules/communication_channels/commands/connect-credential-channel.js +25 -8
  53. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  54. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +2 -2
  55. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  56. package/dist/modules/communication_channels/commands/push-register.js +1 -1
  57. package/dist/modules/communication_channels/commands/push-register.js.map +2 -2
  58. package/dist/modules/communication_channels/commands/push-unregister.js +4 -3
  59. package/dist/modules/communication_channels/commands/push-unregister.js.map +2 -2
  60. package/dist/modules/communication_channels/data/entities.js +4 -0
  61. package/dist/modules/communication_channels/data/entities.js.map +2 -2
  62. package/dist/modules/communication_channels/data/validators.js +2 -1
  63. package/dist/modules/communication_channels/data/validators.js.map +2 -2
  64. package/dist/modules/communication_channels/lib/access-control.js +11 -1
  65. package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
  66. package/dist/modules/communication_channels/lib/connect-channel.js +20 -12
  67. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  68. package/dist/modules/communication_channels/lib/push-adapter.js +68 -0
  69. package/dist/modules/communication_channels/lib/push-adapter.js.map +7 -0
  70. package/dist/modules/communication_channels/lib/push-capabilities.js +31 -0
  71. package/dist/modules/communication_channels/lib/push-capabilities.js.map +7 -0
  72. package/dist/modules/communication_channels/lib/push-connect-error.js +20 -0
  73. package/dist/modules/communication_channels/lib/push-connect-error.js.map +7 -0
  74. package/dist/modules/communication_channels/lib/push-credential-errors.js +41 -0
  75. package/dist/modules/communication_channels/lib/push-credential-errors.js.map +7 -0
  76. package/dist/modules/communication_channels/lib/push-envelope.js +46 -0
  77. package/dist/modules/communication_channels/lib/push-envelope.js.map +7 -0
  78. package/dist/modules/communication_channels/lib/refcounted-client-cache.js +64 -0
  79. package/dist/modules/communication_channels/lib/refcounted-client-cache.js.map +7 -0
  80. package/dist/modules/communication_channels/migrations/Migration20260703123630_communication_channels.js +13 -0
  81. package/dist/modules/communication_channels/migrations/Migration20260703123630_communication_channels.js.map +7 -0
  82. package/dist/modules/communication_channels/notifications.js +4 -0
  83. package/dist/modules/communication_channels/notifications.js.map +2 -2
  84. package/dist/modules/communication_channels/setup.js +2 -0
  85. package/dist/modules/communication_channels/setup.js.map +2 -2
  86. package/dist/modules/configs/lib/upgrade-actions.js +21 -0
  87. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  88. package/dist/modules/customer_accounts/notifications.js +12 -0
  89. package/dist/modules/customer_accounts/notifications.js.map +2 -2
  90. package/dist/modules/customers/notifications.js +4 -0
  91. package/dist/modules/customers/notifications.js.map +2 -2
  92. package/dist/modules/devices/acl.js +11 -0
  93. package/dist/modules/devices/acl.js.map +7 -0
  94. package/dist/modules/devices/api/[id]/route.js +167 -0
  95. package/dist/modules/devices/api/[id]/route.js.map +7 -0
  96. package/dist/modules/devices/api/admin/devices/[id]/route.js +219 -0
  97. package/dist/modules/devices/api/admin/devices/[id]/route.js.map +7 -0
  98. package/dist/modules/devices/api/admin/devices/route.js +140 -0
  99. package/dist/modules/devices/api/admin/devices/route.js.map +7 -0
  100. package/dist/modules/devices/api/auth.js +9 -0
  101. package/dist/modules/devices/api/auth.js.map +7 -0
  102. package/dist/modules/devices/api/deviceList.js +53 -0
  103. package/dist/modules/devices/api/deviceList.js.map +7 -0
  104. package/dist/modules/devices/api/deviceOps.js +118 -0
  105. package/dist/modules/devices/api/deviceOps.js.map +7 -0
  106. package/dist/modules/devices/api/openapi.js +19 -0
  107. package/dist/modules/devices/api/openapi.js.map +7 -0
  108. package/dist/modules/devices/api/route.js +143 -0
  109. package/dist/modules/devices/api/route.js.map +7 -0
  110. package/dist/modules/devices/backend/devices/[id]/page.js +135 -0
  111. package/dist/modules/devices/backend/devices/[id]/page.js.map +7 -0
  112. package/dist/modules/devices/backend/devices/[id]/page.meta.js +16 -0
  113. package/dist/modules/devices/backend/devices/[id]/page.meta.js.map +7 -0
  114. package/dist/modules/devices/backend/devices/create/page.js +68 -0
  115. package/dist/modules/devices/backend/devices/create/page.js.map +7 -0
  116. package/dist/modules/devices/backend/devices/create/page.meta.js +16 -0
  117. package/dist/modules/devices/backend/devices/create/page.meta.js.map +7 -0
  118. package/dist/modules/devices/backend/devices/page.js +235 -0
  119. package/dist/modules/devices/backend/devices/page.js.map +7 -0
  120. package/dist/modules/devices/backend/devices/page.meta.js +23 -0
  121. package/dist/modules/devices/backend/devices/page.meta.js.map +7 -0
  122. package/dist/modules/devices/commands/deactivate.js +107 -0
  123. package/dist/modules/devices/commands/deactivate.js.map +7 -0
  124. package/dist/modules/devices/commands/index.js +4 -0
  125. package/dist/modules/devices/commands/index.js.map +7 -0
  126. package/dist/modules/devices/commands/register.js +183 -0
  127. package/dist/modules/devices/commands/register.js.map +7 -0
  128. package/dist/modules/devices/commands/shared.js +74 -0
  129. package/dist/modules/devices/commands/shared.js.map +7 -0
  130. package/dist/modules/devices/commands/update.js +126 -0
  131. package/dist/modules/devices/commands/update.js.map +7 -0
  132. package/dist/modules/devices/data/entities.js +83 -0
  133. package/dist/modules/devices/data/entities.js.map +7 -0
  134. package/dist/modules/devices/data/validators.js +49 -0
  135. package/dist/modules/devices/data/validators.js.map +7 -0
  136. package/dist/modules/devices/di.js +11 -0
  137. package/dist/modules/devices/di.js.map +7 -0
  138. package/dist/modules/devices/encryption.js +12 -0
  139. package/dist/modules/devices/encryption.js.map +7 -0
  140. package/dist/modules/devices/events.js +19 -0
  141. package/dist/modules/devices/events.js.map +7 -0
  142. package/dist/modules/devices/index.js +17 -0
  143. package/dist/modules/devices/index.js.map +7 -0
  144. package/dist/modules/devices/lib/operationMetadata.js +20 -0
  145. package/dist/modules/devices/lib/operationMetadata.js.map +7 -0
  146. package/dist/modules/devices/migrations/Migration20260602120000.js +15 -0
  147. package/dist/modules/devices/migrations/Migration20260602120000.js.map +7 -0
  148. package/dist/modules/devices/migrations/Migration20260630120000.js +15 -0
  149. package/dist/modules/devices/migrations/Migration20260630120000.js.map +7 -0
  150. package/dist/modules/devices/migrations/Migration20260701120000.js +13 -0
  151. package/dist/modules/devices/migrations/Migration20260701120000.js.map +7 -0
  152. package/dist/modules/devices/migrations/Migration20260721120000.js +15 -0
  153. package/dist/modules/devices/migrations/Migration20260721120000.js.map +7 -0
  154. package/dist/modules/devices/setup.js +13 -0
  155. package/dist/modules/devices/setup.js.map +7 -0
  156. package/dist/modules/entities/cli.js +8 -3
  157. package/dist/modules/entities/cli.js.map +2 -2
  158. package/dist/modules/entities/migrations/Migration20260722120000.js +28 -0
  159. package/dist/modules/entities/migrations/Migration20260722120000.js.map +7 -0
  160. package/dist/modules/inbox_ops/notifications.js +2 -0
  161. package/dist/modules/inbox_ops/notifications.js.map +2 -2
  162. package/dist/modules/messages/notifications.js +2 -0
  163. package/dist/modules/messages/notifications.js.map +2 -2
  164. package/dist/modules/notifications/acl.js +3 -1
  165. package/dist/modules/notifications/acl.js.map +2 -2
  166. package/dist/modules/notifications/api/admin/preferences/route.js +167 -0
  167. package/dist/modules/notifications/api/admin/preferences/route.js.map +7 -0
  168. package/dist/modules/notifications/api/channels/route.js +54 -0
  169. package/dist/modules/notifications/api/channels/route.js.map +7 -0
  170. package/dist/modules/notifications/api/preferences/route.js +126 -0
  171. package/dist/modules/notifications/api/preferences/route.js.map +7 -0
  172. package/dist/modules/notifications/api/route.js +8 -1
  173. package/dist/modules/notifications/api/route.js.map +2 -2
  174. package/dist/modules/notifications/api/types/[id]/channels/[channel]/route.js +187 -0
  175. package/dist/modules/notifications/api/types/[id]/channels/[channel]/route.js.map +7 -0
  176. package/dist/modules/notifications/api/types/route.js +225 -0
  177. package/dist/modules/notifications/api/types/route.js.map +7 -0
  178. package/dist/modules/notifications/api/unread-count/route.js +5 -1
  179. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  180. package/dist/modules/notifications/backend/notifications/user-preferences/page.js +10 -0
  181. package/dist/modules/notifications/backend/notifications/user-preferences/page.js.map +7 -0
  182. package/dist/modules/notifications/backend/notifications/user-preferences/page.meta.js +28 -0
  183. package/dist/modules/notifications/backend/notifications/user-preferences/page.meta.js.map +7 -0
  184. package/dist/modules/notifications/backend/profile/notification-preferences/page.js +10 -0
  185. package/dist/modules/notifications/backend/profile/notification-preferences/page.js.map +7 -0
  186. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +33 -0
  187. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +7 -0
  188. package/dist/modules/notifications/data/entities.js +128 -1
  189. package/dist/modules/notifications/data/entities.js.map +2 -2
  190. package/dist/modules/notifications/data/validators.js +95 -2
  191. package/dist/modules/notifications/data/validators.js.map +2 -2
  192. package/dist/modules/notifications/di.js +7 -0
  193. package/dist/modules/notifications/di.js.map +2 -2
  194. package/dist/modules/notifications/events.js +17 -0
  195. package/dist/modules/notifications/events.js.map +2 -2
  196. package/dist/modules/notifications/frontend/NotificationPreferenceMatrix.js +190 -0
  197. package/dist/modules/notifications/frontend/NotificationPreferenceMatrix.js.map +7 -0
  198. package/dist/modules/notifications/frontend/NotificationPreferencesPageClient.js +124 -0
  199. package/dist/modules/notifications/frontend/NotificationPreferencesPageClient.js.map +7 -0
  200. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +166 -1
  201. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
  202. package/dist/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.js +172 -0
  203. package/dist/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.js.map +7 -0
  204. package/dist/modules/notifications/generators.js +63 -0
  205. package/dist/modules/notifications/generators.js.map +7 -0
  206. package/dist/modules/notifications/lib/delivery-strategies-registry.js +19 -0
  207. package/dist/modules/notifications/lib/delivery-strategies-registry.js.map +7 -0
  208. package/dist/modules/notifications/lib/deliveryStrategies.js.map +2 -2
  209. package/dist/modules/notifications/lib/derive-category.js +8 -0
  210. package/dist/modules/notifications/lib/derive-category.js.map +7 -0
  211. package/dist/modules/notifications/lib/notification-channel-registry.js +35 -0
  212. package/dist/modules/notifications/lib/notification-channel-registry.js.map +7 -0
  213. package/dist/modules/notifications/lib/notification-type-registry.js +87 -0
  214. package/dist/modules/notifications/lib/notification-type-registry.js.map +7 -0
  215. package/dist/modules/notifications/lib/notificationCopy.js +14 -0
  216. package/dist/modules/notifications/lib/notificationCopy.js.map +7 -0
  217. package/dist/modules/notifications/lib/notificationFactory.js +4 -1
  218. package/dist/modules/notifications/lib/notificationFactory.js.map +2 -2
  219. package/dist/modules/notifications/lib/notificationMapper.js +2 -0
  220. package/dist/modules/notifications/lib/notificationMapper.js.map +2 -2
  221. package/dist/modules/notifications/lib/notificationPreferenceService.js +83 -0
  222. package/dist/modules/notifications/lib/notificationPreferenceService.js.map +7 -0
  223. package/dist/modules/notifications/lib/notificationService.js +83 -28
  224. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  225. package/dist/modules/notifications/lib/notificationVisibility.js +24 -0
  226. package/dist/modules/notifications/lib/notificationVisibility.js.map +7 -0
  227. package/dist/modules/notifications/lib/routeHelpers.js +11 -1
  228. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  229. package/dist/modules/notifications/lib/shouldDeliver.js +46 -0
  230. package/dist/modules/notifications/lib/shouldDeliver.js.map +7 -0
  231. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +54 -0
  232. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +7 -0
  233. package/dist/modules/notifications/lib/strategies/in-app-delivery-strategy.js +12 -0
  234. package/dist/modules/notifications/lib/strategies/in-app-delivery-strategy.js.map +7 -0
  235. package/dist/modules/notifications/lib/typeCatalogue.js +71 -0
  236. package/dist/modules/notifications/lib/typeCatalogue.js.map +7 -0
  237. package/dist/modules/notifications/lib/typeChannelSettings.js +12 -0
  238. package/dist/modules/notifications/lib/typeChannelSettings.js.map +7 -0
  239. package/dist/modules/notifications/lib/typeOverrides.js +19 -0
  240. package/dist/modules/notifications/lib/typeOverrides.js.map +7 -0
  241. package/dist/modules/notifications/migrations/Migration20260625122947_notifications.js +14 -0
  242. package/dist/modules/notifications/migrations/Migration20260625122947_notifications.js.map +7 -0
  243. package/dist/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.js +15 -0
  244. package/dist/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.js.map +7 -0
  245. package/dist/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.js +13 -0
  246. package/dist/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.js.map +7 -0
  247. package/dist/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.js +15 -0
  248. package/dist/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.js.map +7 -0
  249. package/dist/modules/notifications/migrations/Migration20260706120000_notifications_channels.js +13 -0
  250. package/dist/modules/notifications/migrations/Migration20260706120000_notifications_channels.js.map +7 -0
  251. package/dist/modules/notifications/migrations/Migration20260716123121_notifications.js +14 -0
  252. package/dist/modules/notifications/migrations/Migration20260716123121_notifications.js.map +7 -0
  253. package/dist/modules/notifications/notification-channels.js +26 -0
  254. package/dist/modules/notifications/notification-channels.js.map +7 -0
  255. package/dist/modules/notifications/notifications.delivery-strategies.js +12 -0
  256. package/dist/modules/notifications/notifications.delivery-strategies.js.map +7 -0
  257. package/dist/modules/notifications/setup.js +16 -1
  258. package/dist/modules/notifications/setup.js.map +2 -2
  259. package/dist/modules/notifications/subscribers/deliver-notification.js +66 -57
  260. package/dist/modules/notifications/subscribers/deliver-notification.js.map +2 -2
  261. package/dist/modules/notifications/subscribers/sync-notification-types.js +16 -0
  262. package/dist/modules/notifications/subscribers/sync-notification-types.js.map +7 -0
  263. package/dist/modules/notifications/widgets/injection/profile-preferences-menu/widget.js +23 -0
  264. package/dist/modules/notifications/widgets/injection/profile-preferences-menu/widget.js.map +7 -0
  265. package/dist/modules/notifications/widgets/injection-table.js +14 -0
  266. package/dist/modules/notifications/widgets/injection-table.js.map +7 -0
  267. package/dist/modules/push_notifications/acl.js +13 -0
  268. package/dist/modules/push_notifications/acl.js.map +7 -0
  269. package/dist/modules/push_notifications/api/custom-send/route.js +141 -0
  270. package/dist/modules/push_notifications/api/custom-send/route.js.map +7 -0
  271. package/dist/modules/push_notifications/api/deliveries/[id]/route.js +110 -0
  272. package/dist/modules/push_notifications/api/deliveries/[id]/route.js.map +7 -0
  273. package/dist/modules/push_notifications/api/deliveries/route.js +56 -0
  274. package/dist/modules/push_notifications/api/deliveries/route.js.map +7 -0
  275. package/dist/modules/push_notifications/api/openapi.js +19 -0
  276. package/dist/modules/push_notifications/api/openapi.js.map +7 -0
  277. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.js +103 -0
  278. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.js.map +7 -0
  279. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.meta.js +13 -0
  280. package/dist/modules/push_notifications/backend/push_notifications/[id]/page.meta.js.map +7 -0
  281. package/dist/modules/push_notifications/backend/push_notifications/page.js +213 -0
  282. package/dist/modules/push_notifications/backend/push_notifications/page.js.map +7 -0
  283. package/dist/modules/push_notifications/backend/push_notifications/page.meta.js +23 -0
  284. package/dist/modules/push_notifications/backend/push_notifications/page.meta.js.map +7 -0
  285. package/dist/modules/push_notifications/backend/push_notifications/send/page.js +245 -0
  286. package/dist/modules/push_notifications/backend/push_notifications/send/page.js.map +7 -0
  287. package/dist/modules/push_notifications/backend/push_notifications/send/page.meta.js +26 -0
  288. package/dist/modules/push_notifications/backend/push_notifications/send/page.meta.js.map +7 -0
  289. package/dist/modules/push_notifications/data/entities.js +92 -0
  290. package/dist/modules/push_notifications/data/entities.js.map +7 -0
  291. package/dist/modules/push_notifications/data/extensions.js +23 -0
  292. package/dist/modules/push_notifications/data/extensions.js.map +7 -0
  293. package/dist/modules/push_notifications/data/validators.js +89 -0
  294. package/dist/modules/push_notifications/data/validators.js.map +7 -0
  295. package/dist/modules/push_notifications/di.js +17 -0
  296. package/dist/modules/push_notifications/di.js.map +7 -0
  297. package/dist/modules/push_notifications/events.js +24 -0
  298. package/dist/modules/push_notifications/events.js.map +7 -0
  299. package/dist/modules/push_notifications/index.js +16 -0
  300. package/dist/modules/push_notifications/index.js.map +7 -0
  301. package/dist/modules/push_notifications/lib/fake-provider-recorder.js +80 -0
  302. package/dist/modules/push_notifications/lib/fake-provider-recorder.js.map +7 -0
  303. package/dist/modules/push_notifications/lib/push-delivery-strategy.js +54 -0
  304. package/dist/modules/push_notifications/lib/push-delivery-strategy.js.map +7 -0
  305. package/dist/modules/push_notifications/lib/push-delivery.js +297 -0
  306. package/dist/modules/push_notifications/lib/push-delivery.js.map +7 -0
  307. package/dist/modules/push_notifications/lib/push-fanout.js +138 -0
  308. package/dist/modules/push_notifications/lib/push-fanout.js.map +7 -0
  309. package/dist/modules/push_notifications/lib/push-health.js +32 -0
  310. package/dist/modules/push_notifications/lib/push-health.js.map +7 -0
  311. package/dist/modules/push_notifications/lib/push-reaper.js +87 -0
  312. package/dist/modules/push_notifications/lib/push-reaper.js.map +7 -0
  313. package/dist/modules/push_notifications/lib/push-receipt-reaper.js +136 -0
  314. package/dist/modules/push_notifications/lib/push-receipt-reaper.js.map +7 -0
  315. package/dist/modules/push_notifications/lib/push-stub-adapter.js +63 -0
  316. package/dist/modules/push_notifications/lib/push-stub-adapter.js.map +7 -0
  317. package/dist/modules/push_notifications/lib/queue.js +53 -0
  318. package/dist/modules/push_notifications/lib/queue.js.map +7 -0
  319. package/dist/modules/push_notifications/lib/reclaim-window.js +13 -0
  320. package/dist/modules/push_notifications/lib/reclaim-window.js.map +7 -0
  321. package/dist/modules/push_notifications/lib/send-custom-push.js +48 -0
  322. package/dist/modules/push_notifications/lib/send-custom-push.js.map +7 -0
  323. package/dist/modules/push_notifications/migrations/Migration20260625150049_push_notifications.js +16 -0
  324. package/dist/modules/push_notifications/migrations/Migration20260625150049_push_notifications.js.map +7 -0
  325. package/dist/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.js +13 -0
  326. package/dist/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.js.map +7 -0
  327. package/dist/modules/push_notifications/notifications.delivery-strategies.js +8 -0
  328. package/dist/modules/push_notifications/notifications.delivery-strategies.js.map +7 -0
  329. package/dist/modules/push_notifications/notifications.js +40 -0
  330. package/dist/modules/push_notifications/notifications.js.map +7 -0
  331. package/dist/modules/push_notifications/setup.js +52 -0
  332. package/dist/modules/push_notifications/setup.js.map +7 -0
  333. package/dist/modules/push_notifications/workers/reclaim-stuck.worker.js +57 -0
  334. package/dist/modules/push_notifications/workers/reclaim-stuck.worker.js.map +7 -0
  335. package/dist/modules/push_notifications/workers/send-push.worker.js +27 -0
  336. package/dist/modules/push_notifications/workers/send-push.worker.js.map +7 -0
  337. package/dist/modules/sales/notifications.js +8 -0
  338. package/dist/modules/sales/notifications.js.map +2 -2
  339. package/dist/modules/staff/notifications.js +6 -0
  340. package/dist/modules/staff/notifications.js.map +2 -2
  341. package/dist/modules/workflows/notifications.js +2 -0
  342. package/dist/modules/workflows/notifications.js.map +2 -2
  343. package/generated/entities/notification/index.ts +3 -0
  344. package/generated/entities/notification_preference/index.ts +8 -0
  345. package/generated/entities/notification_type/index.ts +9 -0
  346. package/generated/entities/notification_type_override/index.ts +7 -0
  347. package/generated/entities/push_notification_delivery/index.ts +19 -0
  348. package/generated/entities/user_device/index.ts +16 -0
  349. package/generated/entities.ids.generated.ts +12 -1
  350. package/generated/entity-fields-registry.ts +72 -0
  351. package/package.json +7 -7
  352. package/src/helpers/integration/api.ts +2 -1
  353. package/src/helpers/integration/appRoot.ts +13 -0
  354. package/src/helpers/integration/dbFixtures.ts +1 -5
  355. package/src/helpers/integration/pushFake.ts +219 -0
  356. package/src/modules/auth/i18n/de.json +1 -0
  357. package/src/modules/auth/i18n/en.json +1 -0
  358. package/src/modules/auth/i18n/es.json +1 -0
  359. package/src/modules/auth/i18n/ko.json +1 -0
  360. package/src/modules/auth/i18n/pl.json +1 -0
  361. package/src/modules/auth/notifications.ts +16 -0
  362. package/src/modules/business_rules/i18n/de.json +2 -1
  363. package/src/modules/business_rules/i18n/en.json +2 -1
  364. package/src/modules/business_rules/i18n/es.json +2 -1
  365. package/src/modules/business_rules/i18n/ko.json +2 -1
  366. package/src/modules/business_rules/i18n/pl.json +2 -1
  367. package/src/modules/business_rules/notifications.ts +2 -0
  368. package/src/modules/catalog/i18n/de.json +2 -1
  369. package/src/modules/catalog/i18n/en.json +2 -1
  370. package/src/modules/catalog/i18n/es.json +2 -1
  371. package/src/modules/catalog/i18n/ko.json +2 -1
  372. package/src/modules/catalog/i18n/pl.json +2 -1
  373. package/src/modules/catalog/notifications.ts +2 -0
  374. package/src/modules/communication_channels/acl.ts +9 -0
  375. package/src/modules/communication_channels/api/delete/admin/channels/[id]/route.ts +168 -0
  376. package/src/modules/communication_channels/api/get/channels/[id]/health/route.ts +6 -2
  377. package/src/modules/communication_channels/api/get/channels/[id]/route.ts +6 -2
  378. package/src/modules/communication_channels/api/get/channels/route.ts +2 -1
  379. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +16 -5
  380. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +37 -1
  381. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +175 -0
  382. package/src/modules/communication_channels/backend/communication_channels/channels/page.tsx +11 -1
  383. package/src/modules/communication_channels/commands/admin-delete-channel.ts +203 -0
  384. package/src/modules/communication_channels/commands/connect-credential-channel.ts +63 -15
  385. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +10 -2
  386. package/src/modules/communication_channels/commands/push-register.ts +7 -1
  387. package/src/modules/communication_channels/commands/push-unregister.ts +14 -3
  388. package/src/modules/communication_channels/data/entities.ts +10 -0
  389. package/src/modules/communication_channels/data/validators.ts +1 -0
  390. package/src/modules/communication_channels/i18n/de.json +35 -1
  391. package/src/modules/communication_channels/i18n/en.json +35 -1
  392. package/src/modules/communication_channels/i18n/es.json +35 -1
  393. package/src/modules/communication_channels/i18n/ko.json +35 -1
  394. package/src/modules/communication_channels/i18n/pl.json +35 -1
  395. package/src/modules/communication_channels/lib/access-control.ts +42 -0
  396. package/src/modules/communication_channels/lib/adapter.ts +25 -0
  397. package/src/modules/communication_channels/lib/connect-channel.ts +61 -26
  398. package/src/modules/communication_channels/lib/push-adapter.ts +139 -0
  399. package/src/modules/communication_channels/lib/push-capabilities.ts +42 -0
  400. package/src/modules/communication_channels/lib/push-connect-error.ts +52 -0
  401. package/src/modules/communication_channels/lib/push-credential-errors.ts +58 -0
  402. package/src/modules/communication_channels/lib/push-envelope.ts +104 -0
  403. package/src/modules/communication_channels/lib/refcounted-client-cache.ts +123 -0
  404. package/src/modules/communication_channels/migrations/.snapshot-open-mercato.json +9 -0
  405. package/src/modules/communication_channels/migrations/Migration20260703123630_communication_channels.ts +20 -0
  406. package/src/modules/communication_channels/notifications.ts +4 -0
  407. package/src/modules/communication_channels/setup.ts +2 -0
  408. package/src/modules/configs/i18n/de.json +5 -1
  409. package/src/modules/configs/i18n/en.json +5 -1
  410. package/src/modules/configs/i18n/es.json +5 -1
  411. package/src/modules/configs/i18n/ko.json +5 -1
  412. package/src/modules/configs/i18n/pl.json +5 -1
  413. package/src/modules/configs/lib/upgrade-actions.ts +21 -0
  414. package/src/modules/customer_accounts/i18n/de.json +2 -1
  415. package/src/modules/customer_accounts/i18n/en.json +2 -1
  416. package/src/modules/customer_accounts/i18n/es.json +2 -1
  417. package/src/modules/customer_accounts/i18n/ko.json +2 -1
  418. package/src/modules/customer_accounts/i18n/pl.json +2 -1
  419. package/src/modules/customer_accounts/notifications.ts +12 -0
  420. package/src/modules/customers/i18n/de.json +2 -1
  421. package/src/modules/customers/i18n/en.json +2 -1
  422. package/src/modules/customers/i18n/es.json +2 -1
  423. package/src/modules/customers/i18n/ko.json +2 -1
  424. package/src/modules/customers/i18n/pl.json +2 -1
  425. package/src/modules/customers/notifications.ts +4 -0
  426. package/src/modules/devices/AGENTS.md +101 -0
  427. package/src/modules/devices/acl.ts +7 -0
  428. package/src/modules/devices/api/[id]/route.ts +180 -0
  429. package/src/modules/devices/api/admin/devices/[id]/route.ts +235 -0
  430. package/src/modules/devices/api/admin/devices/route.ts +160 -0
  431. package/src/modules/devices/api/auth.ts +13 -0
  432. package/src/modules/devices/api/deviceList.ts +54 -0
  433. package/src/modules/devices/api/deviceOps.ts +186 -0
  434. package/src/modules/devices/api/openapi.ts +23 -0
  435. package/src/modules/devices/api/route.ts +165 -0
  436. package/src/modules/devices/backend/devices/[id]/page.meta.ts +12 -0
  437. package/src/modules/devices/backend/devices/[id]/page.tsx +164 -0
  438. package/src/modules/devices/backend/devices/create/page.meta.ts +12 -0
  439. package/src/modules/devices/backend/devices/create/page.tsx +81 -0
  440. package/src/modules/devices/backend/devices/page.meta.ts +21 -0
  441. package/src/modules/devices/backend/devices/page.tsx +259 -0
  442. package/src/modules/devices/commands/deactivate.ts +126 -0
  443. package/src/modules/devices/commands/index.ts +3 -0
  444. package/src/modules/devices/commands/register.ts +199 -0
  445. package/src/modules/devices/commands/shared.ts +119 -0
  446. package/src/modules/devices/commands/update.ts +142 -0
  447. package/src/modules/devices/data/entities.ts +79 -0
  448. package/src/modules/devices/data/validators.ts +65 -0
  449. package/src/modules/devices/di.ts +9 -0
  450. package/src/modules/devices/encryption.ts +15 -0
  451. package/src/modules/devices/events.ts +16 -0
  452. package/src/modules/devices/i18n/de.json +45 -0
  453. package/src/modules/devices/i18n/en.json +45 -0
  454. package/src/modules/devices/i18n/es.json +45 -0
  455. package/src/modules/devices/i18n/ko.json +45 -0
  456. package/src/modules/devices/i18n/pl.json +45 -0
  457. package/src/modules/devices/index.ts +15 -0
  458. package/src/modules/devices/lib/operationMetadata.ts +35 -0
  459. package/src/modules/devices/migrations/.snapshot-open-mercato.json +200 -0
  460. package/src/modules/devices/migrations/Migration20260602120000.ts +15 -0
  461. package/src/modules/devices/migrations/Migration20260630120000.ts +20 -0
  462. package/src/modules/devices/migrations/Migration20260701120000.ts +13 -0
  463. package/src/modules/devices/migrations/Migration20260721120000.ts +18 -0
  464. package/src/modules/devices/setup.ts +11 -0
  465. package/src/modules/entities/cli.ts +16 -2
  466. package/src/modules/entities/migrations/Migration20260722120000.ts +38 -0
  467. package/src/modules/inbox_ops/i18n/de.json +2 -1
  468. package/src/modules/inbox_ops/i18n/en.json +2 -1
  469. package/src/modules/inbox_ops/i18n/es.json +2 -1
  470. package/src/modules/inbox_ops/i18n/ko.json +2 -1
  471. package/src/modules/inbox_ops/i18n/pl.json +2 -1
  472. package/src/modules/inbox_ops/notifications.ts +2 -0
  473. package/src/modules/messages/i18n/de.json +2 -1
  474. package/src/modules/messages/i18n/en.json +2 -1
  475. package/src/modules/messages/i18n/es.json +2 -1
  476. package/src/modules/messages/i18n/ko.json +2 -1
  477. package/src/modules/messages/i18n/pl.json +2 -1
  478. package/src/modules/messages/notifications.ts +2 -0
  479. package/src/modules/notifications/AGENTS.md +54 -0
  480. package/src/modules/notifications/acl.ts +2 -0
  481. package/src/modules/notifications/api/admin/preferences/route.ts +193 -0
  482. package/src/modules/notifications/api/channels/route.ts +54 -0
  483. package/src/modules/notifications/api/preferences/route.ts +131 -0
  484. package/src/modules/notifications/api/route.ts +8 -1
  485. package/src/modules/notifications/api/types/[id]/channels/[channel]/route.ts +226 -0
  486. package/src/modules/notifications/api/types/route.ts +239 -0
  487. package/src/modules/notifications/api/unread-count/route.ts +5 -1
  488. package/src/modules/notifications/backend/notifications/user-preferences/page.meta.ts +26 -0
  489. package/src/modules/notifications/backend/notifications/user-preferences/page.tsx +12 -0
  490. package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +31 -0
  491. package/src/modules/notifications/backend/profile/notification-preferences/page.tsx +12 -0
  492. package/src/modules/notifications/data/entities.ts +144 -0
  493. package/src/modules/notifications/data/validators.ts +116 -0
  494. package/src/modules/notifications/di.ts +7 -0
  495. package/src/modules/notifications/events.ts +23 -0
  496. package/src/modules/notifications/frontend/NotificationPreferenceMatrix.tsx +287 -0
  497. package/src/modules/notifications/frontend/NotificationPreferencesPageClient.tsx +155 -0
  498. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +273 -2
  499. package/src/modules/notifications/frontend/NotificationUserPreferencesAdminPageClient.tsx +214 -0
  500. package/src/modules/notifications/generators.ts +82 -0
  501. package/src/modules/notifications/i18n/de.json +43 -0
  502. package/src/modules/notifications/i18n/en.json +43 -0
  503. package/src/modules/notifications/i18n/es.json +43 -0
  504. package/src/modules/notifications/i18n/ko.json +43 -0
  505. package/src/modules/notifications/i18n/pl.json +44 -1
  506. package/src/modules/notifications/lib/delivery-strategies-registry.ts +30 -0
  507. package/src/modules/notifications/lib/deliveryStrategies.ts +40 -4
  508. package/src/modules/notifications/lib/derive-category.ts +15 -0
  509. package/src/modules/notifications/lib/notification-channel-registry.ts +59 -0
  510. package/src/modules/notifications/lib/notification-type-registry.ts +182 -0
  511. package/src/modules/notifications/lib/notificationCopy.ts +34 -0
  512. package/src/modules/notifications/lib/notificationFactory.ts +5 -1
  513. package/src/modules/notifications/lib/notificationMapper.ts +2 -0
  514. package/src/modules/notifications/lib/notificationPreferenceService.ts +138 -0
  515. package/src/modules/notifications/lib/notificationService.ts +129 -25
  516. package/src/modules/notifications/lib/notificationVisibility.ts +48 -0
  517. package/src/modules/notifications/lib/routeHelpers.ts +14 -1
  518. package/src/modules/notifications/lib/shouldDeliver.ts +112 -0
  519. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +63 -0
  520. package/src/modules/notifications/lib/strategies/in-app-delivery-strategy.ts +21 -0
  521. package/src/modules/notifications/lib/typeCatalogue.ts +141 -0
  522. package/src/modules/notifications/lib/typeChannelSettings.ts +29 -0
  523. package/src/modules/notifications/lib/typeOverrides.ts +46 -0
  524. package/src/modules/notifications/migrations/.snapshot-open-mercato.json +874 -100
  525. package/src/modules/notifications/migrations/Migration20260625122947_notifications.ts +14 -0
  526. package/src/modules/notifications/migrations/Migration20260626120500_notifications_push_payload.ts +15 -0
  527. package/src/modules/notifications/migrations/Migration20260626130000_notifications_non_opt_out.ts +13 -0
  528. package/src/modules/notifications/migrations/Migration20260626140000_notifications_type_category_silent.ts +15 -0
  529. package/src/modules/notifications/migrations/Migration20260706120000_notifications_channels.ts +13 -0
  530. package/src/modules/notifications/migrations/Migration20260716123121_notifications.ts +14 -0
  531. package/src/modules/notifications/notification-channels.ts +30 -0
  532. package/src/modules/notifications/notifications.delivery-strategies.ts +15 -0
  533. package/src/modules/notifications/setup.ts +23 -1
  534. package/src/modules/notifications/subscribers/deliver-notification.ts +105 -71
  535. package/src/modules/notifications/subscribers/sync-notification-types.ts +18 -0
  536. package/src/modules/notifications/widgets/injection/profile-preferences-menu/widget.ts +35 -0
  537. package/src/modules/notifications/widgets/injection-table.ts +18 -0
  538. package/src/modules/push_notifications/AGENTS.md +92 -0
  539. package/src/modules/push_notifications/acl.ts +9 -0
  540. package/src/modules/push_notifications/api/custom-send/route.ts +175 -0
  541. package/src/modules/push_notifications/api/deliveries/[id]/route.ts +122 -0
  542. package/src/modules/push_notifications/api/deliveries/route.ts +58 -0
  543. package/src/modules/push_notifications/api/openapi.ts +22 -0
  544. package/src/modules/push_notifications/backend/push_notifications/[id]/page.meta.ts +9 -0
  545. package/src/modules/push_notifications/backend/push_notifications/[id]/page.tsx +149 -0
  546. package/src/modules/push_notifications/backend/push_notifications/page.meta.ts +21 -0
  547. package/src/modules/push_notifications/backend/push_notifications/page.tsx +239 -0
  548. package/src/modules/push_notifications/backend/push_notifications/send/page.meta.ts +24 -0
  549. package/src/modules/push_notifications/backend/push_notifications/send/page.tsx +327 -0
  550. package/src/modules/push_notifications/data/entities.ts +111 -0
  551. package/src/modules/push_notifications/data/extensions.ts +29 -0
  552. package/src/modules/push_notifications/data/validators.ts +102 -0
  553. package/src/modules/push_notifications/di.ts +20 -0
  554. package/src/modules/push_notifications/events.ts +21 -0
  555. package/src/modules/push_notifications/i18n/de.json +79 -0
  556. package/src/modules/push_notifications/i18n/en.json +79 -0
  557. package/src/modules/push_notifications/i18n/es.json +79 -0
  558. package/src/modules/push_notifications/i18n/ko.json +79 -0
  559. package/src/modules/push_notifications/i18n/pl.json +79 -0
  560. package/src/modules/push_notifications/index.ts +15 -0
  561. package/src/modules/push_notifications/lib/fake-provider-recorder.ts +154 -0
  562. package/src/modules/push_notifications/lib/push-delivery-strategy.ts +90 -0
  563. package/src/modules/push_notifications/lib/push-delivery.ts +455 -0
  564. package/src/modules/push_notifications/lib/push-fanout.ts +237 -0
  565. package/src/modules/push_notifications/lib/push-health.ts +47 -0
  566. package/src/modules/push_notifications/lib/push-reaper.ts +140 -0
  567. package/src/modules/push_notifications/lib/push-receipt-reaper.ts +210 -0
  568. package/src/modules/push_notifications/lib/push-stub-adapter.ts +80 -0
  569. package/src/modules/push_notifications/lib/queue.ts +74 -0
  570. package/src/modules/push_notifications/lib/reclaim-window.ts +16 -0
  571. package/src/modules/push_notifications/lib/send-custom-push.ts +100 -0
  572. package/src/modules/push_notifications/migrations/.snapshot-open-mercato.json +386 -0
  573. package/src/modules/push_notifications/migrations/Migration20260625150049_push_notifications.ts +16 -0
  574. package/src/modules/push_notifications/migrations/Migration20260626120000_push_notifications_silent.ts +13 -0
  575. package/src/modules/push_notifications/notifications.delivery-strategies.ts +8 -0
  576. package/src/modules/push_notifications/notifications.ts +50 -0
  577. package/src/modules/push_notifications/setup.ts +92 -0
  578. package/src/modules/push_notifications/workers/reclaim-stuck.worker.ts +78 -0
  579. package/src/modules/push_notifications/workers/send-push.worker.ts +34 -0
  580. package/src/modules/sales/i18n/de.json +1 -0
  581. package/src/modules/sales/i18n/en.json +1 -0
  582. package/src/modules/sales/i18n/es.json +1 -0
  583. package/src/modules/sales/i18n/ko.json +1 -0
  584. package/src/modules/sales/i18n/pl.json +1 -0
  585. package/src/modules/sales/notifications.ts +8 -0
  586. package/src/modules/staff/i18n/de.json +1 -0
  587. package/src/modules/staff/i18n/en.json +1 -0
  588. package/src/modules/staff/i18n/es.json +1 -0
  589. package/src/modules/staff/i18n/ko.json +1 -0
  590. package/src/modules/staff/i18n/pl.json +1 -0
  591. package/src/modules/staff/notifications.ts +6 -0
  592. package/src/modules/workflows/i18n/de.json +1 -0
  593. package/src/modules/workflows/i18n/en.json +1 -0
  594. package/src/modules/workflows/i18n/es.json +1 -0
  595. package/src/modules/workflows/i18n/ko.json +1 -0
  596. package/src/modules/workflows/i18n/pl.json +1 -0
  597. package/src/modules/workflows/notifications.ts +2 -0
@@ -0,0 +1,155 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
7
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
10
+ import {
11
+ NotificationPreferenceMatrix,
12
+ PREFERENCE_CHANNELS,
13
+ buildPreferenceMap,
14
+ diffPreferenceItems,
15
+ preferenceKey,
16
+ toChannelDef,
17
+ type ChannelDef,
18
+ type NotificationTypeItem,
19
+ type PreferenceItem,
20
+ } from './NotificationPreferenceMatrix'
21
+
22
+ type TypesResponse = { items?: NotificationTypeItem[] }
23
+ type PreferencesResponse = { items?: PreferenceItem[] }
24
+ type ChannelsResponse = { items?: Array<{ id: string; labelKey: string; descriptionKey?: string | null }> }
25
+ type SaveResponse = { ok?: boolean; error?: string }
26
+
27
+ const PREFERENCES_CONTEXT_ID = 'notifications-preferences'
28
+
29
+ export function NotificationPreferencesPageClient() {
30
+ const t = useT()
31
+ const [types, setTypes] = React.useState<NotificationTypeItem[] | null>(null)
32
+ const [channels, setChannels] = React.useState<ChannelDef[]>(PREFERENCE_CHANNELS)
33
+ const [prefs, setPrefs] = React.useState<Record<string, boolean>>({})
34
+ // The state as last loaded/saved; saves send only the entries that differ from this.
35
+ const initialPrefs = React.useRef<Record<string, boolean>>({})
36
+ const [loading, setLoading] = React.useState(true)
37
+ const [saving, setSaving] = React.useState(false)
38
+ const [error, setError] = React.useState<string | null>(null)
39
+
40
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
41
+ formId: string
42
+ resourceKind: string
43
+ retryLastMutation: () => Promise<boolean>
44
+ }>({
45
+ contextId: PREFERENCES_CONTEXT_ID,
46
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
47
+ })
48
+
49
+ const fetchData = React.useCallback(async () => {
50
+ setLoading(true)
51
+ setError(null)
52
+ try {
53
+ const [typesBody, prefsBody, channelsBody] = await Promise.all([
54
+ readApiResultOrThrow<TypesResponse>('/api/notifications/types', undefined, {
55
+ errorMessage: t('notifications.preferences.loadError', 'Failed to load notification preferences'),
56
+ allowNullResult: true,
57
+ }),
58
+ readApiResultOrThrow<PreferencesResponse>('/api/notifications/preferences', undefined, {
59
+ errorMessage: t('notifications.preferences.loadError', 'Failed to load notification preferences'),
60
+ allowNullResult: true,
61
+ }),
62
+ readApiResultOrThrow<ChannelsResponse>('/api/notifications/channels', undefined, {
63
+ errorMessage: t('notifications.preferences.loadError', 'Failed to load notification preferences'),
64
+ allowNullResult: true,
65
+ }),
66
+ ])
67
+ const typeItems = typesBody?.items ?? []
68
+ setTypes(typeItems)
69
+ const channelDefs = channelsBody?.items?.length ? channelsBody.items.map(toChannelDef) : PREFERENCE_CHANNELS
70
+ setChannels(channelDefs)
71
+ const map = buildPreferenceMap(typeItems, prefsBody?.items ?? [], channelDefs)
72
+ initialPrefs.current = map
73
+ setPrefs(map)
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : t('notifications.preferences.loadError', 'Failed to load notification preferences')
76
+ setError(message)
77
+ flash(message, 'error')
78
+ } finally {
79
+ setLoading(false)
80
+ }
81
+ }, [t])
82
+
83
+ React.useEffect(() => {
84
+ fetchData()
85
+ }, [fetchData])
86
+
87
+ const togglePref = (typeId: string, channel: string, enabled: boolean) => {
88
+ setPrefs((prev) => ({ ...prev, [preferenceKey(typeId, channel)]: enabled }))
89
+ }
90
+
91
+ const handleSave = async () => {
92
+ if (!types) return
93
+ setSaving(true)
94
+ try {
95
+ const preferences = diffPreferenceItems(types, initialPrefs.current, prefs, channels)
96
+ // Nothing changed since load/last save — skip the no-op write entirely.
97
+ if (preferences.length === 0) {
98
+ flash(t('notifications.preferences.saveSuccess', 'Notification preferences saved'), 'success')
99
+ return
100
+ }
101
+ const response = await runMutation({
102
+ // optimistic-lock-exempt: the preference matrix is an idempotent per-cell toggle grid.
103
+ // The client sends only changed (type, channel) cells (diffPreferenceItems) and the server
104
+ // upserts them last-write-wins per cell, so there is no lost-update hazard on a shared
105
+ // aggregate that version-locking would guard.
106
+ operation: () =>
107
+ apiCall<SaveResponse>('/api/notifications/preferences', {
108
+ method: 'PUT',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ preferences }),
111
+ }),
112
+ context: { formId: PREFERENCES_CONTEXT_ID, resourceKind: 'notifications.preference', retryLastMutation },
113
+ mutationPayload: { preferences },
114
+ })
115
+ if (!response.ok) {
116
+ const message = response.result?.error || t('notifications.preferences.saveError', 'Failed to save notification preferences')
117
+ throw new Error(message)
118
+ }
119
+ initialPrefs.current = prefs
120
+ flash(t('notifications.preferences.saveSuccess', 'Notification preferences saved'), 'success')
121
+ } catch (err) {
122
+ const message = err instanceof Error ? err.message : t('notifications.preferences.saveError', 'Failed to save notification preferences')
123
+ flash(message, 'error')
124
+ } finally {
125
+ setSaving(false)
126
+ }
127
+ }
128
+
129
+ if (loading || !types) {
130
+ return <LoadingMessage label={t('notifications.preferences.loading', 'Loading notification preferences...')} />
131
+ }
132
+
133
+ return (
134
+ <div className="flex flex-col gap-6">
135
+ <div>
136
+ <h1 className="text-2xl font-semibold">{t('notifications.preferences.pageTitle', 'Notification Preferences')}</h1>
137
+ <p className="text-muted-foreground text-sm">
138
+ {t('notifications.preferences.pageDescription', 'Choose which channels deliver each notification type. Unset choices stay enabled by default.')}
139
+ </p>
140
+ </div>
141
+
142
+ <NotificationPreferenceMatrix types={types} prefs={prefs} onToggle={togglePref} channels={channels} />
143
+
144
+ {error && <ErrorMessage label={error} />}
145
+
146
+ <div className="flex items-center gap-3">
147
+ <Button type="button" onClick={handleSave} disabled={saving}>
148
+ {saving ? t('notifications.preferences.saving', 'Saving...') : t('notifications.preferences.save', 'Save preferences')}
149
+ </Button>
150
+ </div>
151
+ </div>
152
+ )
153
+ }
154
+
155
+ export default NotificationPreferencesPageClient
@@ -2,9 +2,11 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
- import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
7
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
6
8
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
7
- import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { flash, type FlashKind } from '@open-mercato/ui/backend/FlashMessages'
8
10
  import { Button } from '@open-mercato/ui/primitives/button'
9
11
  import { Input } from '@open-mercato/ui/primitives/input'
10
12
  import { Label } from '@open-mercato/ui/primitives/label'
@@ -12,6 +14,26 @@ import { Switch } from '@open-mercato/ui/primitives/switch'
12
14
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
13
15
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@open-mercato/ui/primitives/card'
14
16
 
17
+ type NotificationTypeCatalogueItem = {
18
+ id: string
19
+ labelKey: string
20
+ descriptionKey?: string | null
21
+ // Effective "required" flag (tenant override ?? code-declared).
22
+ nonOptOut?: boolean
23
+ // Effective channel eligibility for this tenant (stored override ?? code-declared; null = every channel).
24
+ channels: string[] | null
25
+ // Raw tenant-stored override (null = inherit the code-declared set).
26
+ storedChannels: string[] | null
27
+ // Raw tenant-stored nonOptOut override (null = inherit the code-declared flag).
28
+ storedNonOptOut: boolean | null
29
+ // Optimistic-lock version of the tenant's override row (null = no override stored yet).
30
+ updatedAt: string | null
31
+ }
32
+
33
+ type TypesResponse = { items?: NotificationTypeCatalogueItem[] }
34
+ type ChannelsResponse = { items?: Array<{ id: string; labelKey: string; descriptionKey?: string | null }> }
35
+ type PatchTypeResponse = { ok?: boolean; item?: NotificationTypeCatalogueItem; error?: string }
36
+
15
37
  type NotificationDeliveryConfig = {
16
38
  appUrl?: string
17
39
  panelPath: string
@@ -48,12 +70,170 @@ export function NotificationSettingsPageClient() {
48
70
  const { runMutation, retryLastMutation } = useGuardedMutation<{
49
71
  formId: string
50
72
  resourceKind: string
73
+ resourceId?: string
51
74
  retryLastMutation: () => Promise<boolean>
52
75
  }>({
53
76
  contextId: SETTINGS_CONTEXT_ID,
54
77
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
55
78
  })
56
79
 
80
+ const [types, setTypes] = React.useState<NotificationTypeCatalogueItem[]>([])
81
+ const [channels, setChannels] = React.useState<Array<{ id: string; labelKey: string }>>([])
82
+ const [savingTypeCell, setSavingTypeCell] = React.useState<string | null>(null)
83
+
84
+ const fetchCatalogue = React.useCallback(async () => {
85
+ try {
86
+ const [typesBody, channelsBody] = await Promise.all([
87
+ readApiResultOrThrow<TypesResponse>('/api/notifications/types', undefined, {
88
+ errorMessage: t('notifications.settings.types.loadError', 'Failed to load notification types'),
89
+ allowNullResult: true,
90
+ }),
91
+ readApiResultOrThrow<ChannelsResponse>('/api/notifications/channels', undefined, {
92
+ errorMessage: t('notifications.settings.types.loadError', 'Failed to load notification types'),
93
+ allowNullResult: true,
94
+ }),
95
+ ])
96
+ setTypes(typesBody?.items ?? [])
97
+ setChannels((channelsBody?.items ?? []).map((item) => ({ id: item.id, labelKey: item.labelKey })))
98
+ } catch (err) {
99
+ const message = err instanceof Error ? err.message : t('notifications.settings.types.loadError', 'Failed to load notification types')
100
+ flash(message, 'error')
101
+ }
102
+ }, [t])
103
+
104
+ React.useEffect(() => {
105
+ fetchCatalogue()
106
+ }, [fetchCatalogue])
107
+
108
+ const saveTypeCell = async (
109
+ type: NotificationTypeCatalogueItem,
110
+ cellKey: string,
111
+ mutationPayload: Record<string, unknown>,
112
+ operation: () => Promise<Awaited<ReturnType<typeof apiCall<PatchTypeResponse>>>>,
113
+ resolveSavedFlash?: (saved: NotificationTypeCatalogueItem) => { message: string; kind: FlashKind } | null,
114
+ ) => {
115
+ setSavingTypeCell(cellKey)
116
+ try {
117
+ const response = await runMutation({
118
+ operation,
119
+ context: {
120
+ formId: SETTINGS_CONTEXT_ID,
121
+ resourceKind: 'notifications.settings',
122
+ resourceId: type.id,
123
+ retryLastMutation,
124
+ },
125
+ mutationPayload,
126
+ })
127
+ if (!response.ok || !response.result?.ok) {
128
+ if (surfaceRecordConflict({ status: response.status, body: response.result }, t, { onRefresh: fetchCatalogue })) {
129
+ await fetchCatalogue()
130
+ return
131
+ }
132
+ const message = response.result?.error || t('notifications.settings.types.saveError', 'Failed to save notification type settings')
133
+ throw new Error(message)
134
+ }
135
+ const saved = response.result.item
136
+ setTypes((prev) =>
137
+ prev.map((item) =>
138
+ item.id === type.id && saved
139
+ ? {
140
+ ...item,
141
+ channels: saved.channels,
142
+ storedChannels: saved.storedChannels,
143
+ nonOptOut: saved.nonOptOut,
144
+ storedNonOptOut: saved.storedNonOptOut,
145
+ updatedAt: saved.updatedAt,
146
+ }
147
+ : item,
148
+ ),
149
+ )
150
+ const savedFlash = saved ? resolveSavedFlash?.(saved) ?? null : null
151
+ flash(
152
+ savedFlash?.message ?? t('notifications.settings.types.saveSuccess', 'Notification type settings saved'),
153
+ savedFlash?.kind ?? 'success',
154
+ )
155
+ } catch (err) {
156
+ const message = err instanceof Error ? err.message : t('notifications.settings.types.saveError', 'Failed to save notification type settings')
157
+ flash(message, 'error')
158
+ } finally {
159
+ setSavingTypeCell(null)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * `nonOptOut` is a single scalar the request replaces wholesale, so it keeps the
165
+ * optimistic-lock header: a concurrent operator save flips the override row's `updatedAt`
166
+ * and the server 409s rather than letting a stale blind write revert their edit.
167
+ */
168
+ const patchType = (
169
+ type: NotificationTypeCatalogueItem,
170
+ cellKey: string,
171
+ payload: { channels?: string[] | null; nonOptOut?: boolean | null },
172
+ ) =>
173
+ saveTypeCell(type, cellKey, { id: type.id, ...payload }, () =>
174
+ withScopedApiRequestHeaders(buildOptimisticLockHeader(type.updatedAt), () =>
175
+ apiCall<PatchTypeResponse>('/api/notifications/types', {
176
+ method: 'PATCH',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({ id: type.id, ...payload }),
179
+ }),
180
+ ),
181
+ )
182
+
183
+ /**
184
+ * optimistic-lock-exempt: notification type channel add/remove. Addressing the single
185
+ * channel lets the server derive the next eligibility set from the CURRENT stored state
186
+ * under a row lock, so two operators toggling different channels both land instead of the
187
+ * later one clobbering the earlier with a client-computed array. There is no shared
188
+ * aggregate left for a version check to protect — the same reason the sibling preference
189
+ * matrix and the customers/catalog add-remove endpoints are exempt.
190
+ */
191
+ const toggleTypeChannel = (
192
+ type: NotificationTypeCatalogueItem,
193
+ cellKey: string,
194
+ channel: string,
195
+ enabled: boolean,
196
+ ) =>
197
+ saveTypeCell(
198
+ type,
199
+ cellKey,
200
+ { id: type.id, channel, enabled },
201
+ () =>
202
+ apiCall<PatchTypeResponse>(
203
+ `/api/notifications/types/${encodeURIComponent(type.id)}/channels/${encodeURIComponent(channel)}`,
204
+ { method: enabled ? 'PUT' : 'DELETE' },
205
+ ),
206
+ // Unchecking the last channel clears the override instead of storing an empty set (which would
207
+ // black-hole the type), so the code-declared default reapplies and the box the operator just
208
+ // unticked can come back ticked. The write succeeded, so say what happened rather than letting a
209
+ // green "saved" sit next to a checkbox that visibly ignored the click.
210
+ (saved) =>
211
+ !enabled && saved.storedChannels === null && (saved.channels === null || saved.channels.includes(channel))
212
+ ? {
213
+ message: t(
214
+ 'notifications.settings.types.lastChannelRestored',
215
+ 'A notification type must keep at least one channel, so the module default was restored.',
216
+ ),
217
+ kind: 'warning' as FlashKind,
218
+ }
219
+ : null,
220
+ )
221
+
222
+ const handleTypeChannelToggle = async (
223
+ type: NotificationTypeCatalogueItem,
224
+ channelId: string,
225
+ enabled: boolean,
226
+ ) => {
227
+ // The server resolves the base set (stored override ?? code declaration ?? every
228
+ // registered channel) and clears the override when the last channel is unchecked, so the
229
+ // client no longer computes — or can stale-clobber — the whole array.
230
+ await toggleTypeChannel(type, `${type.id}::${channelId}`, channelId, enabled)
231
+ }
232
+
233
+ const handleTypeNonOptOutToggle = async (type: NotificationTypeCatalogueItem, required: boolean) => {
234
+ await patchType(type, `${type.id}::nonOptOut`, { nonOptOut: required })
235
+ }
236
+
57
237
  const fetchSettings = React.useCallback(async () => {
58
238
  setLoading(true)
59
239
  setError(null)
@@ -237,6 +417,97 @@ export function NotificationSettingsPageClient() {
237
417
  </CardContent>
238
418
  </Card>
239
419
 
420
+ <Card>
421
+ <CardHeader>
422
+ <CardTitle>{t('notifications.settings.types.title', 'Delivery channels per type')}</CardTitle>
423
+ <CardDescription>
424
+ {t(
425
+ 'notifications.settings.types.description',
426
+ 'Delivery channels per notification type. Turning a channel off disables it completely for that type in your tenant — it never delivers and users cannot enable it in their preferences. Turning it on lets users opt in or out per type; required (non-opt-out) types always deliver on enabled channels. Your setting overrides the module default.',
427
+ )}
428
+ </CardDescription>
429
+ </CardHeader>
430
+ <CardContent>
431
+ {types.length === 0 || channels.length === 0 ? (
432
+ <p className="text-sm text-muted-foreground">
433
+ {t('notifications.settings.types.empty', 'No notification types are registered yet.')}
434
+ </p>
435
+ ) : (
436
+ <div className="overflow-x-auto rounded-lg border border-border">
437
+ <table className="w-full text-sm">
438
+ <thead>
439
+ <tr className="border-b border-border bg-muted/50 text-left">
440
+ <th className="px-4 py-3 font-medium">
441
+ {t('notifications.settings.types.typeColumn', 'Notification type')}
442
+ </th>
443
+ {channels.map((channel) => (
444
+ <th key={channel.id} className="px-4 py-3 font-medium">
445
+ {t(channel.labelKey, channel.id)}
446
+ </th>
447
+ ))}
448
+ <th className="px-4 py-3 font-medium">
449
+ <div>{t('notifications.settings.types.requiredColumn', 'Required')}</div>
450
+ <div className="text-xs font-normal text-muted-foreground">
451
+ {t('notifications.settings.types.requiredHint', 'Users cannot opt out when on.')}
452
+ </div>
453
+ </th>
454
+ </tr>
455
+ </thead>
456
+ <tbody>
457
+ {types.map((type) => (
458
+ <tr key={type.id} className="border-b border-border last:border-0">
459
+ <td className="px-4 py-3">
460
+ <div className="font-medium">{t(type.labelKey, type.id)}</div>
461
+ {type.descriptionKey ? (
462
+ <div className="text-xs text-muted-foreground">{t(type.descriptionKey, '')}</div>
463
+ ) : null}
464
+ </td>
465
+ {channels.map((channel) => {
466
+ const cellKey = `${type.id}::${channel.id}`
467
+ const channelEnabled = type.channels === null || type.channels.includes(channel.id)
468
+ return (
469
+ <td key={channel.id} className="px-4 py-3">
470
+ {/* The saving spinner is absolutely positioned so it never joins the cell's
471
+ layout flow — an in-flow sibling would widen the column mid-toggle and
472
+ make the whole table jump for the duration of the save. */}
473
+ <span className="relative inline-flex items-center">
474
+ <Switch
475
+ checked={channelEnabled}
476
+ disabled={savingTypeCell !== null}
477
+ aria-label={`${t(type.labelKey, type.id)} – ${t(channel.labelKey, channel.id)}`}
478
+ onCheckedChange={(checked) =>
479
+ handleTypeChannelToggle(type, channel.id, checked)
480
+ }
481
+ />
482
+ {savingTypeCell === cellKey ? (
483
+ <Spinner size="sm" className="absolute left-full ml-2" />
484
+ ) : null}
485
+ </span>
486
+ </td>
487
+ )
488
+ })}
489
+ <td className="px-4 py-3">
490
+ <span className="relative inline-flex">
491
+ <Switch
492
+ checked={type.nonOptOut === true}
493
+ disabled={savingTypeCell !== null}
494
+ aria-label={`${t(type.labelKey, type.id)} – ${t('notifications.settings.types.requiredColumn', 'Required')}`}
495
+ onCheckedChange={(checked) => handleTypeNonOptOutToggle(type, checked)}
496
+ />
497
+ {savingTypeCell === `${type.id}::nonOptOut` ? (
498
+ <Spinner size="sm" className="absolute left-full ml-2" />
499
+ ) : null}
500
+ </span>
501
+ </td>
502
+ </tr>
503
+ ))}
504
+ </tbody>
505
+ </table>
506
+ </div>
507
+ )}
508
+ </CardContent>
509
+ </Card>
510
+
240
511
  <div className="flex items-center gap-3">
241
512
  <Button type="button" onClick={handleSave} disabled={saving}>
242
513
  {saving ? t('notifications.settings.saving', 'Saving...') : t('notifications.settings.save', 'Save settings')}
@@ -0,0 +1,214 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
7
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { LoadingMessage } from '@open-mercato/ui/backend/detail'
10
+ import { LookupSelect, type LookupSelectItem } from '@open-mercato/ui/backend/inputs'
11
+ import {
12
+ NotificationPreferenceMatrix,
13
+ PREFERENCE_CHANNELS,
14
+ buildPreferenceMap,
15
+ diffPreferenceItems,
16
+ preferenceKey,
17
+ toChannelDef,
18
+ type ChannelDef,
19
+ type NotificationTypeItem,
20
+ type PreferenceItem,
21
+ } from './NotificationPreferenceMatrix'
22
+
23
+ type TypesResponse = { items?: NotificationTypeItem[] }
24
+ type PreferencesResponse = { items?: PreferenceItem[] }
25
+ type ChannelsResponse = { items?: Array<{ id: string; labelKey: string; descriptionKey?: string | null }> }
26
+ type SaveResponse = { ok?: boolean; error?: string }
27
+
28
+ type UserRow = { id: string; name?: string | null; email?: string | null }
29
+ type UsersResponse = { items?: UserRow[] }
30
+
31
+ const ADMIN_PREFERENCES_CONTEXT_ID = 'notifications-admin-preferences'
32
+
33
+ function userLabel(user: UserRow): string {
34
+ return user.name?.trim() || user.email?.trim() || user.id
35
+ }
36
+
37
+ export function NotificationUserPreferencesAdminPageClient() {
38
+ const t = useT()
39
+ const [types, setTypes] = React.useState<NotificationTypeItem[]>([])
40
+ const [channels, setChannels] = React.useState<ChannelDef[]>(PREFERENCE_CHANNELS)
41
+ const [typesLoading, setTypesLoading] = React.useState(true)
42
+ const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null)
43
+ // id -> display label, populated as options are fetched so the heading can name the selected user.
44
+ const userLabels = React.useRef<Map<string, string>>(new Map())
45
+ const [prefs, setPrefs] = React.useState<Record<string, boolean>>({})
46
+ // Baseline for the selected user; saves send only entries that differ from this.
47
+ const initialPrefs = React.useRef<Record<string, boolean>>({})
48
+ const [prefsLoading, setPrefsLoading] = React.useState(false)
49
+ const [saving, setSaving] = React.useState(false)
50
+
51
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
52
+ formId: string
53
+ resourceKind: string
54
+ retryLastMutation: () => Promise<boolean>
55
+ }>({
56
+ contextId: ADMIN_PREFERENCES_CONTEXT_ID,
57
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
58
+ })
59
+
60
+ React.useEffect(() => {
61
+ let cancelled = false
62
+ ;(async () => {
63
+ setTypesLoading(true)
64
+ try {
65
+ const [body, channelsBody] = await Promise.all([
66
+ readApiResultOrThrow<TypesResponse>('/api/notifications/types', undefined, {
67
+ errorMessage: t('notifications.preferences.loadError', 'Failed to load notification preferences'),
68
+ allowNullResult: true,
69
+ }),
70
+ readApiResultOrThrow<ChannelsResponse>('/api/notifications/channels', undefined, {
71
+ errorMessage: t('notifications.preferences.loadError', 'Failed to load notification preferences'),
72
+ allowNullResult: true,
73
+ }),
74
+ ])
75
+ if (!cancelled) {
76
+ setTypes(body?.items ?? [])
77
+ setChannels(channelsBody?.items?.length ? channelsBody.items.map(toChannelDef) : PREFERENCE_CHANNELS)
78
+ }
79
+ } catch (err) {
80
+ if (!cancelled) flash(err instanceof Error ? err.message : t('notifications.preferences.loadError', 'Failed to load notification preferences'), 'error')
81
+ } finally {
82
+ if (!cancelled) setTypesLoading(false)
83
+ }
84
+ })()
85
+ return () => { cancelled = true }
86
+ }, [t])
87
+
88
+ // Async user lookup for LookupSelect. Devices admins may lack auth.users.list; degrade to no
89
+ // options (x-om-forbidden-redirect: 0 + swallow) instead of redirecting the whole page.
90
+ const fetchUsers = React.useCallback(async (query?: string): Promise<LookupSelectItem[]> => {
91
+ const params = new URLSearchParams({ page: '1', pageSize: '10' })
92
+ if (query && query.trim().length) params.set('search', query.trim())
93
+ const call = await apiCall<UsersResponse>(
94
+ `/api/auth/users?${params.toString()}`,
95
+ { headers: { 'x-om-forbidden-redirect': '0' } },
96
+ { fallback: null },
97
+ ).catch(() => null)
98
+ if (!call || !call.ok) return []
99
+ return (call.result?.items ?? []).flatMap((user) => {
100
+ if (!user || typeof user.id !== 'string' || !user.id.trim()) return []
101
+ const label = userLabel(user)
102
+ userLabels.current.set(user.id, label)
103
+ const email = user.email?.trim() ?? null
104
+ return [{ id: user.id, title: label, subtitle: email && email !== label ? email : null }]
105
+ })
106
+ }, [])
107
+
108
+ const loadPreferencesFor = React.useCallback(async (userId: string) => {
109
+ setPrefsLoading(true)
110
+ try {
111
+ const body = await readApiResultOrThrow<PreferencesResponse>(
112
+ `/api/notifications/admin/preferences?userId=${encodeURIComponent(userId)}`,
113
+ undefined,
114
+ { errorMessage: t('notifications.preferences.loadError', 'Failed to load notification preferences'), allowNullResult: true },
115
+ )
116
+ const map = buildPreferenceMap(types, body?.items ?? [], channels)
117
+ initialPrefs.current = map
118
+ setPrefs(map)
119
+ } catch (err) {
120
+ flash(err instanceof Error ? err.message : t('notifications.preferences.loadError', 'Failed to load notification preferences'), 'error')
121
+ setSelectedUserId(null)
122
+ } finally {
123
+ setPrefsLoading(false)
124
+ }
125
+ }, [t, types, channels])
126
+
127
+ const handleSelectUser = (userId: string | null) => {
128
+ setSelectedUserId(userId)
129
+ if (userId) void loadPreferencesFor(userId)
130
+ }
131
+
132
+ const togglePref = (typeId: string, channel: string, enabled: boolean) => {
133
+ setPrefs((prev) => ({ ...prev, [preferenceKey(typeId, channel)]: enabled }))
134
+ }
135
+
136
+ const handleSave = async () => {
137
+ if (!selectedUserId) return
138
+ setSaving(true)
139
+ try {
140
+ const preferences = diffPreferenceItems(types, initialPrefs.current, prefs, channels)
141
+ // Nothing changed for this user since load/last save — skip the no-op write.
142
+ if (preferences.length === 0) {
143
+ flash(t('notifications.preferences.saveSuccess', 'Notification preferences saved'), 'success')
144
+ return
145
+ }
146
+ const response = await runMutation({
147
+ // optimistic-lock-exempt: idempotent per-cell preference toggle grid — the client sends only
148
+ // changed (type, channel) cells (diffPreferenceItems) and the server upserts them
149
+ // last-write-wins per cell, so there is no lost-update hazard to version-lock.
150
+ operation: () =>
151
+ apiCall<SaveResponse>('/api/notifications/admin/preferences', {
152
+ method: 'PUT',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ userId: selectedUserId, preferences }),
155
+ }),
156
+ context: { formId: ADMIN_PREFERENCES_CONTEXT_ID, resourceKind: 'notifications.preference', retryLastMutation },
157
+ mutationPayload: { userId: selectedUserId, preferences },
158
+ })
159
+ if (!response.ok) {
160
+ throw new Error(response.result?.error || t('notifications.preferences.saveError', 'Failed to save notification preferences'))
161
+ }
162
+ initialPrefs.current = prefs
163
+ flash(t('notifications.preferences.saveSuccess', 'Notification preferences saved'), 'success')
164
+ } catch (err) {
165
+ flash(err instanceof Error ? err.message : t('notifications.preferences.saveError', 'Failed to save notification preferences'), 'error')
166
+ } finally {
167
+ setSaving(false)
168
+ }
169
+ }
170
+
171
+ const selectedLabel = selectedUserId ? (userLabels.current.get(selectedUserId) ?? selectedUserId) : ''
172
+
173
+ return (
174
+ <div className="flex flex-col gap-6">
175
+ <div>
176
+ <h1 className="text-2xl font-semibold">{t('notifications.preferences.admin.pageTitle', 'User Notification Preferences')}</h1>
177
+ <p className="text-muted-foreground text-sm">
178
+ {t('notifications.preferences.admin.pageDescription', "Search for a user to review and edit their notification channel preferences.")}
179
+ </p>
180
+ </div>
181
+
182
+ <LookupSelect
183
+ value={selectedUserId}
184
+ onChange={handleSelectUser}
185
+ fetchOptions={fetchUsers}
186
+ defaultOpen
187
+ searchPlaceholder={t('notifications.preferences.admin.searchPlaceholder', 'Search users by name or email...')}
188
+ emptyLabel={t('notifications.preferences.admin.noUsers', 'No users found.')}
189
+ />
190
+
191
+ {selectedUserId ? (
192
+ <div className="flex flex-col gap-4">
193
+ <h2 className="text-lg font-medium">
194
+ {t('notifications.preferences.admin.editingFor', 'Preferences for {user}').replace('{user}', selectedLabel)}
195
+ </h2>
196
+ {prefsLoading || typesLoading ? (
197
+ <LoadingMessage label={t('notifications.preferences.loading', 'Loading notification preferences...')} />
198
+ ) : (
199
+ <>
200
+ <NotificationPreferenceMatrix types={types} prefs={prefs} onToggle={togglePref} channels={channels} />
201
+ <div>
202
+ <Button type="button" onClick={handleSave} disabled={saving}>
203
+ {saving ? t('notifications.preferences.saving', 'Saving...') : t('notifications.preferences.save', 'Save preferences')}
204
+ </Button>
205
+ </div>
206
+ </>
207
+ )}
208
+ </div>
209
+ ) : null}
210
+ </div>
211
+ )
212
+ }
213
+
214
+ export default NotificationUserPreferencesAdminPageClient