@cosmicdrift/kumiko-bundled-features 0.1.0

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 (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,33 @@
1
+ // Feature name
2
+ export const DELIVERY_FEATURE = "delivery" as const;
3
+
4
+ // Qualified write handler names (QN format: scope:type:name)
5
+ export const DeliveryHandlers = {
6
+ setPreference: "delivery:write:set-preference",
7
+ } as const;
8
+
9
+ // Qualified query handler names (QN format: scope:type:name)
10
+ export const DeliveryQueries = {
11
+ log: "delivery:query:log",
12
+ preferences: "delivery:query:preferences",
13
+ } as const;
14
+
15
+ // Error codes
16
+ export const DeliveryErrors = {
17
+ noRecipient: "delivery_no_recipient",
18
+ channelFailed: "delivery_channel_failed",
19
+ } as const;
20
+
21
+ // Delivery status values
22
+ export const DeliveryStatus = {
23
+ sent: "sent",
24
+ failed: "failed",
25
+ skipped: "skipped",
26
+ } as const;
27
+
28
+ export type DeliveryStatusValue = (typeof DeliveryStatus)[keyof typeof DeliveryStatus];
29
+
30
+ // Qualified domain-event name. Emitted by the delivery-service on every
31
+ // attempt (sent / failed / skipped). A multi-stream-projection materialises
32
+ // each event into a row in deliveryAttemptsTable for the log-query handler.
33
+ export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
@@ -0,0 +1,489 @@
1
+ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { NotifyPriority, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { append } from "@cosmicdrift/kumiko-framework/event-store";
7
+ import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
8
+ import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
9
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
10
+ import { and, eq, or } from "drizzle-orm";
11
+ import type { Redis } from "ioredis";
12
+ import { DELIVERY_ATTEMPT_EVENT } from "./constants";
13
+ import { deliveryAttemptSchema } from "./events";
14
+ import { notificationPreferencesTable } from "./tables";
15
+ import type {
16
+ ChannelContext,
17
+ ChannelMessage,
18
+ DeliveryChannel,
19
+ DeliveryLogEntry,
20
+ DeliveryService,
21
+ NotificationRenderer,
22
+ } from "./types";
23
+
24
+ export type RateLimitConfig = {
25
+ readonly redis: Redis;
26
+ readonly maxPerHour: number; // per channel per tenant
27
+ readonly keyPrefix?: string;
28
+ };
29
+
30
+ export type KillSwitchResolver = (tenantId: TenantId, channelName: string) => Promise<boolean>;
31
+
32
+ export type DeliveryServiceOptions = {
33
+ readonly db: DbConnection;
34
+ readonly registry: Registry;
35
+ readonly sseBroker?: SseBroker;
36
+ readonly channels: readonly DeliveryChannel[];
37
+ readonly tenantUserIdsQuery?: string;
38
+ readonly rateLimit?: RateLimitConfig;
39
+ readonly isChannelKilled?: KillSwitchResolver; // returns true if channel is disabled for tenant
40
+ // Redis handle used for idempotencyKey dedup. Falls back to rateLimit.redis.
41
+ // Must be present whenever callers rely on idempotencyKey, otherwise notify()
42
+ // throws at the callsite (silent no-op would be a correctness bug).
43
+ readonly idempotencyRedis?: Redis;
44
+ };
45
+
46
+ // Build channel list from registry extension usages
47
+ export function collectChannels(registry: Registry): DeliveryChannel[] {
48
+ const usages = registry.getExtensionUsages("deliveryChannel");
49
+ return usages.map((usage) => {
50
+ // @cast-boundary engine-payload — extension-usage carries unknown options
51
+ const opts = usage.options as {
52
+ resolve: DeliveryChannel["resolve"];
53
+ send: DeliveryChannel["send"];
54
+ };
55
+ return { name: usage.entityName, resolve: opts.resolve, send: opts.send };
56
+ });
57
+ }
58
+
59
+ // Build renderer map from registry extension usages
60
+ export function collectRenderers(registry: Registry): Map<string, NotificationRenderer> {
61
+ const usages = registry.getExtensionUsages("notificationRenderer");
62
+ const map = new Map<string, NotificationRenderer>();
63
+ for (const usage of usages) {
64
+ // @cast-boundary engine-payload — extension-usage carries unknown options
65
+ const opts = usage.options as { render: NotificationRenderer["render"] };
66
+ map.set(usage.entityName, { name: usage.entityName, render: opts.render });
67
+ }
68
+ return map;
69
+ }
70
+
71
+ export function createDeliveryService(options: DeliveryServiceOptions): DeliveryService {
72
+ const {
73
+ db,
74
+ registry,
75
+ sseBroker,
76
+ channels,
77
+ tenantUserIdsQuery,
78
+ rateLimit,
79
+ isChannelKilled,
80
+ idempotencyRedis,
81
+ } = options;
82
+ const idemRedis = idempotencyRedis ?? rateLimit?.redis;
83
+
84
+ // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
85
+ // Lua. Runs single-threaded in Redis, so two parallel clients can't both
86
+ // observe `count <= max` and slip past. The prior non-atomic JS version
87
+ // could leave the counter stuck below the true hit count when two INCRs
88
+ // raced into simultaneous DECR rollbacks.
89
+ const RATE_LIMIT_LUA = `
90
+ local count = redis.call('INCR', KEYS[1])
91
+ if count == 1 then
92
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
93
+ end
94
+ if count > tonumber(ARGV[2]) then
95
+ redis.call('DECR', KEYS[1])
96
+ return 0
97
+ end
98
+ return 1
99
+ `;
100
+
101
+ type RedisWithLua = Redis & {
102
+ deliveryRateLimitCheck: (key: string, ttl: string, max: string) => Promise<number>;
103
+ };
104
+
105
+ if (rateLimit) {
106
+ // Register the Lua script once per Redis client. Noop if already defined.
107
+ // @cast-boundary engine-bridge — defineCommand attaches the Lua method post-boot
108
+ const r = rateLimit.redis as Partial<Pick<RedisWithLua, "deliveryRateLimitCheck">> & Redis;
109
+ if (!r.deliveryRateLimitCheck) {
110
+ r.defineCommand("deliveryRateLimitCheck", { numberOfKeys: 1, lua: RATE_LIMIT_LUA });
111
+ }
112
+ }
113
+
114
+ async function checkRateLimit(
115
+ rl: RateLimitConfig,
116
+ tenantId: TenantId,
117
+ channelName: string,
118
+ ): Promise<boolean> {
119
+ const key = `${rl.keyPrefix ?? "delivery:rate"}:${tenantId}:${channelName}`;
120
+ // @cast-boundary engine-bridge — defineCommand attaches the Lua method shape at boot
121
+ const r = rl.redis as RedisWithLua;
122
+ const allowed = await r.deliveryRateLimitCheck(key, "3600", String(rl.maxPerHour));
123
+ return Number(allowed) === 1;
124
+ }
125
+
126
+ // Idempotency: returns true the first time a key is seen, false on
127
+ // subsequent calls within the TTL window. Opt-in via options.idempotencyKey
128
+ // so callers decide when dedup matters (e.g. webhook replays, button
129
+ // double-clicks). Requires a Redis handle — configured via idempotencyRedis
130
+ // or reused from rateLimit.redis. notify() throws if the key is used without
131
+ // a backing Redis, so misconfigurations fail loud instead of silently double-sending.
132
+ async function claimIdempotency(
133
+ tenantId: TenantId,
134
+ key: string,
135
+ ttlSec = 86400,
136
+ ): Promise<boolean> {
137
+ if (!idemRedis) {
138
+ throw new Error(
139
+ "Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
140
+ );
141
+ }
142
+ const k = `delivery:idem:${tenantId}:${key}`;
143
+ const res = await idemRedis.set(k, "1", "EX", ttlSec, "NX");
144
+ return res === "OK";
145
+ }
146
+
147
+ async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
148
+ if (!tenantUserIdsQuery) {
149
+ throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
150
+ }
151
+ const handler = registry.getQueryHandler(tenantUserIdsQuery);
152
+ if (!handler) {
153
+ throw new Error(`Tenant broadcast query "${tenantUserIdsQuery}" not found in registry`);
154
+ }
155
+ const systemUser = createSystemUser(tenantId);
156
+ const tenantDb = createTenantDb(db, tenantId, "system");
157
+ // @cast-boundary engine-payload — generic query-handler return for typed convention
158
+ return (await handler.handler(
159
+ { type: tenantUserIdsQuery, payload: { tenantId }, user: systemUser },
160
+ { db: tenantDb, registry, ...bridgeStub() },
161
+ )) as readonly string[];
162
+ }
163
+
164
+ function buildChannelContext(tenantId: TenantId): ChannelContext {
165
+ return { db, registry, sseBroker, tenantId };
166
+ }
167
+
168
+ async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
169
+ // Post-ES: each delivery attempt is a standalone event on its own
170
+ // aggregate stream (fresh UUID per attempt). The `delivery-log` inline
171
+ // projection materialises the same row shape into deliveryAttemptsTable.
172
+ // Low-level append() does NOT auto-fire inline projections (only the
173
+ // dispatcher / executor / ctx.appendEvent paths do), so we invoke
174
+ // runProjectionsForEvent manually to keep the write synchronous with
175
+ // the projection update — same TX, read-your-own-write semantics.
176
+ const attemptId = generateId();
177
+ const { tenantId, ...rest } = entry;
178
+ // Schema-parse to match ctx.appendEvent's guarantee: a payload drift
179
+ // between service + feature-registration fails loudly here instead of
180
+ // landing on the events-table and crashing a consumer later.
181
+ const payload = deliveryAttemptSchema.parse(rest);
182
+ const stored = await append(db, {
183
+ aggregateId: attemptId,
184
+ aggregateType: "deliveryAttempt",
185
+ tenantId,
186
+ expectedVersion: 0,
187
+ type: DELIVERY_ATTEMPT_EVENT,
188
+ payload,
189
+ metadata: { userId: "system" },
190
+ });
191
+ await runProjectionsForEvent(stored, registry, db);
192
+ }
193
+
194
+ function buildMessage(
195
+ notificationType: string,
196
+ data: Readonly<Record<string, unknown>> | undefined,
197
+ channelName: string,
198
+ ): ChannelMessage {
199
+ // Look up per-channel template from notification definition
200
+ const notifDef = registry.getAllNotifications().get(notificationType);
201
+ const templateFn = notifDef?.templates?.[channelName];
202
+
203
+ if (templateFn && data) {
204
+ const channelData = templateFn(data as DbRow);
205
+ // @cast-boundary engine-payload — generic notification.data + channel-template result
206
+ return {
207
+ notificationType,
208
+ title: (channelData["title"] as string) ?? (data["title"] as string) ?? notificationType,
209
+ body: channelData["body"] as string | undefined,
210
+ data: channelData,
211
+ };
212
+ }
213
+
214
+ // @cast-boundary engine-payload — generic notification.data shape
215
+ return {
216
+ notificationType,
217
+ title: (data?.["title"] as string) ?? notificationType,
218
+ body: data?.["body"] as string | undefined,
219
+ data,
220
+ };
221
+ }
222
+
223
+ // Check if user has disabled this notification+channel combo.
224
+ // Specificity order: exact > any wildcard. When only wildcards match and they
225
+ // disagree, "disabled wins" — the user has asked to be opted out somewhere,
226
+ // and an exact override is the way to punch through it. Without this rule
227
+ // the outcome would depend on row insertion order in the DB.
228
+ // Example:
229
+ // { type: "*", channel: "inApp", enabled: false } disables inApp globally
230
+ // { type: "orderAssigned", channel: "*", enabled: true } enables orderAssigned everywhere
231
+ // → orderAssigned on inApp: disabled (conservative) unless an exact entry overrides.
232
+ async function isChannelEnabled(
233
+ userId: string,
234
+ tenantId: TenantId,
235
+ notificationType: string,
236
+ channelName: string,
237
+ ): Promise<boolean> {
238
+ type PrefRow = {
239
+ readonly notificationType: string;
240
+ readonly channel: string;
241
+ readonly enabled: boolean;
242
+ };
243
+ // Drizzle's dynamic-table select() loses column types; assert once at
244
+ // the boundary so the rest of this function works against a typed shape.
245
+ const prefs = (await db
246
+ .select({
247
+ notificationType: notificationPreferencesTable.notificationType,
248
+ channel: notificationPreferencesTable.channel,
249
+ enabled: notificationPreferencesTable.enabled,
250
+ })
251
+ .from(notificationPreferencesTable)
252
+ .where(
253
+ and(
254
+ eq(notificationPreferencesTable.tenantId, tenantId),
255
+ eq(notificationPreferencesTable.userId, userId),
256
+ or(
257
+ and(
258
+ eq(notificationPreferencesTable.notificationType, notificationType),
259
+ eq(notificationPreferencesTable.channel, channelName),
260
+ ),
261
+ and(
262
+ eq(notificationPreferencesTable.notificationType, "*"),
263
+ eq(notificationPreferencesTable.channel, channelName),
264
+ ),
265
+ and(
266
+ eq(notificationPreferencesTable.notificationType, notificationType),
267
+ eq(notificationPreferencesTable.channel, "*"),
268
+ ),
269
+ ),
270
+ ),
271
+ )) as readonly PrefRow[]; // @cast-boundary db-row
272
+
273
+ if (prefs.length === 0) return true;
274
+
275
+ // Exact match (both specific) wins over any wildcard
276
+ const exact = prefs.find(
277
+ (p) => p.notificationType === notificationType && p.channel === channelName,
278
+ );
279
+ if (exact) return exact.enabled;
280
+
281
+ // Only wildcards matched: any disabled entry disables delivery (deterministic
282
+ // and conservative — DB ordering no longer decides the outcome).
283
+ return !prefs.some((p) => p.enabled === false);
284
+ }
285
+
286
+ async function deliverToUser(
287
+ userId: string,
288
+ notificationType: string,
289
+ data: Readonly<Record<string, unknown>> | undefined,
290
+ tenantId: TenantId,
291
+ priority: NotifyPriority,
292
+ ): Promise<void> {
293
+ const channelCtx = buildChannelContext(tenantId);
294
+
295
+ for (const channel of channels) {
296
+ const message = buildMessage(notificationType, data, channel.name);
297
+
298
+ // Kill switch: tenant admin disabled this channel entirely
299
+ if (isChannelKilled) {
300
+ const killed = await isChannelKilled(tenantId, channel.name);
301
+ if (killed) {
302
+ await logDelivery({
303
+ tenantId,
304
+ notificationType,
305
+ channel: channel.name,
306
+ recipientId: userId,
307
+ recipientAddress: null,
308
+ status: "skipped",
309
+ error: "channel_disabled",
310
+ });
311
+ continue;
312
+ }
313
+ }
314
+
315
+ // Check preferences (critical priority skips preference check)
316
+ if (priority !== "critical") {
317
+ const enabled = await isChannelEnabled(userId, tenantId, notificationType, channel.name);
318
+ if (!enabled) {
319
+ await logDelivery({
320
+ tenantId,
321
+ notificationType,
322
+ channel: channel.name,
323
+ recipientId: userId,
324
+ recipientAddress: null,
325
+ status: "skipped",
326
+ error: "preference_disabled",
327
+ });
328
+ continue;
329
+ }
330
+ }
331
+
332
+ // Rate limiting
333
+ if (rateLimit) {
334
+ const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
335
+ if (!allowed) {
336
+ await logDelivery({
337
+ tenantId,
338
+ notificationType,
339
+ channel: channel.name,
340
+ recipientId: userId,
341
+ recipientAddress: null,
342
+ status: "skipped",
343
+ error: "rate_limited",
344
+ });
345
+ continue;
346
+ }
347
+ }
348
+
349
+ try {
350
+ const address = await channel.resolve(userId, channelCtx);
351
+ if (!address) {
352
+ await logDelivery({
353
+ tenantId,
354
+ notificationType,
355
+ channel: channel.name,
356
+ recipientId: userId,
357
+ recipientAddress: null,
358
+ status: "skipped",
359
+ error: "no_address",
360
+ });
361
+ continue;
362
+ }
363
+
364
+ const result = await channel.send(address, message, channelCtx);
365
+ await logDelivery({
366
+ tenantId,
367
+ notificationType,
368
+ channel: channel.name,
369
+ recipientId: userId,
370
+ recipientAddress: result.address ?? address,
371
+ status: result.status,
372
+ error: result.error ?? null,
373
+ });
374
+ } catch (err) {
375
+ await logDelivery({
376
+ tenantId,
377
+ notificationType,
378
+ channel: channel.name,
379
+ recipientId: userId,
380
+ recipientAddress: null,
381
+ status: "failed",
382
+ error: err instanceof Error ? err.message : String(err),
383
+ });
384
+ }
385
+ }
386
+ }
387
+
388
+ async function deliverDirect(
389
+ route: Readonly<Record<string, string>>,
390
+ notificationType: string,
391
+ data: Readonly<Record<string, unknown>> | undefined,
392
+ tenantId: TenantId,
393
+ ): Promise<void> {
394
+ const channelCtx = buildChannelContext(tenantId);
395
+
396
+ // Direct routing skips preferences (no user account) but NOT rate limit
397
+ // — direct sends can still be abused (webhook replays, test harnesses).
398
+ for (const channel of channels) {
399
+ const address = route[channel.name];
400
+ const message = buildMessage(notificationType, data, channel.name);
401
+ if (!address) continue;
402
+
403
+ if (rateLimit) {
404
+ const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
405
+ if (!allowed) {
406
+ await logDelivery({
407
+ tenantId,
408
+ notificationType,
409
+ channel: channel.name,
410
+ recipientId: null,
411
+ recipientAddress: address,
412
+ status: "skipped",
413
+ error: "rate_limited",
414
+ });
415
+ continue;
416
+ }
417
+ }
418
+
419
+ try {
420
+ const result = await channel.send(address, message, channelCtx);
421
+ await logDelivery({
422
+ tenantId,
423
+ notificationType,
424
+ channel: channel.name,
425
+ recipientId: null,
426
+ recipientAddress: result.address ?? address,
427
+ status: result.status,
428
+ error: result.error ?? null,
429
+ });
430
+ } catch (err) {
431
+ await logDelivery({
432
+ tenantId,
433
+ notificationType,
434
+ channel: channel.name,
435
+ recipientId: null,
436
+ recipientAddress: address,
437
+ status: "failed",
438
+ error: err instanceof Error ? err.message : String(err),
439
+ });
440
+ }
441
+ }
442
+ }
443
+
444
+ return {
445
+ async notify(notificationType, options, _user, tenantId) {
446
+ const { to, route, data, idempotencyKey } = options;
447
+ const priority: NotifyPriority = options.priority ?? "normal";
448
+
449
+ if (idempotencyKey) {
450
+ const first = await claimIdempotency(tenantId, idempotencyKey);
451
+ if (!first) {
452
+ await logDelivery({
453
+ tenantId,
454
+ notificationType,
455
+ channel: "*",
456
+ recipientId: null,
457
+ recipientAddress: null,
458
+ status: "skipped",
459
+ error: "duplicate_idempotency_key",
460
+ });
461
+ // skip: duplicate send deduped via idempotency key, logged above
462
+ return;
463
+ }
464
+ }
465
+
466
+ if (route) {
467
+ await deliverDirect(route, notificationType, data, tenantId);
468
+ // skip: direct route delivered, no recipient resolution needed
469
+ return;
470
+ }
471
+
472
+ if (to !== undefined) {
473
+ let userIds: readonly string[];
474
+
475
+ if (typeof to === "string") {
476
+ userIds = [to];
477
+ } else if ("tenant" in to) {
478
+ userIds = await resolveUserIdsForTenant(to.tenant);
479
+ } else {
480
+ userIds = to;
481
+ }
482
+
483
+ for (const userId of userIds) {
484
+ await deliverToUser(userId, notificationType, data, tenantId, priority);
485
+ }
486
+ }
487
+ },
488
+ };
489
+ }
@@ -0,0 +1,18 @@
1
+ // Event-payload schema for the deliveryAttempt aggregate. Shared between
2
+ // delivery-feature.ts (registers it via r.defineEvent) and
3
+ // delivery-service.ts (validates payloads before the low-level append()
4
+ // — out-of-dispatcher writes otherwise skip schema enforcement).
5
+
6
+ import { z } from "zod";
7
+ import { DeliveryStatus } from "./constants";
8
+
9
+ export const deliveryAttemptSchema = z.object({
10
+ notificationType: z.string(),
11
+ channel: z.string(),
12
+ recipientId: z.string().nullable(),
13
+ recipientAddress: z.string().nullable(),
14
+ status: z.enum([DeliveryStatus.sent, DeliveryStatus.failed, DeliveryStatus.skipped]),
15
+ error: z.string().nullable(),
16
+ });
17
+
18
+ export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
@@ -0,0 +1,70 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { z } from "zod";
3
+ import { DELIVERY_ATTEMPT_EVENT } from "./constants";
4
+ import { deliveryAttemptSchema } from "./events";
5
+ import { logQuery } from "./handlers/log.query";
6
+ import { preferencesQuery } from "./handlers/preferences.query";
7
+ import { setPreferenceWrite } from "./handlers/set-preference.write";
8
+ import { deliveryAttemptsTable, notificationPreferenceEntity } from "./tables";
9
+
10
+ export function createDeliveryFeature(): FeatureDefinition {
11
+ return defineFeature("delivery", (r) => {
12
+ r.systemScope();
13
+ r.entity("notification-preference", notificationPreferenceEntity);
14
+
15
+ // Events-only projection source: "deliveryAttempt" is the aggregate-
16
+ // type on the events-table, but there's no r.entity for it — each
17
+ // attempt is a fresh stream, no CRUD lifecycle. Framework's
18
+ // boot-validator accepts the projection below because at least one
19
+ // apply-key is a registered domain-event (DELIVERY_ATTEMPT_EVENT).
20
+ r.defineEvent("attempt", deliveryAttemptSchema);
21
+
22
+ // Inline projection that materialises every delivery attempt into
23
+ // deliveryAttemptsTable. Runs in the SAME transaction as the low-level
24
+ // append(), so callers see their write immediately — no dispatcher
25
+ // drain needed in tests. Chosen over a MultiStreamProjection because
26
+ // delivery-log is a hot read-path for admin/audit UIs that expect
27
+ // read-your-own-write semantics.
28
+ r.projection({
29
+ name: "delivery-log",
30
+ source: "deliveryAttempt",
31
+ table: deliveryAttemptsTable,
32
+ apply: {
33
+ [DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
34
+ const p = event.payload as z.infer<typeof deliveryAttemptSchema>;
35
+ // PK = aggregateId — replaying the same event twice conflicts on
36
+ // the PK rather than silently duplicating the log row.
37
+ await tx.insert(deliveryAttemptsTable).values({
38
+ id: event.aggregateId,
39
+ tenantId: event.tenantId,
40
+ notificationType: p.notificationType,
41
+ channel: p.channel,
42
+ recipientId: p.recipientId,
43
+ recipientAddress: p.recipientAddress,
44
+ status: p.status,
45
+ error: p.error,
46
+ });
47
+ },
48
+ },
49
+ });
50
+
51
+ // Extension points: channels and renderers register as features
52
+ r.extendsRegistrar("deliveryChannel", {
53
+ onRegister: () => {},
54
+ });
55
+ r.extendsRegistrar("notificationRenderer", {
56
+ onRegister: () => {},
57
+ });
58
+
59
+ const handlers = {
60
+ setPreference: r.writeHandler(setPreferenceWrite),
61
+ };
62
+
63
+ const queries = {
64
+ log: r.queryHandler(logQuery),
65
+ preferences: r.queryHandler(preferencesQuery),
66
+ };
67
+
68
+ return { handlers, queries };
69
+ });
70
+ }
@@ -0,0 +1,21 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { desc } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { deliveryAttemptsTable } from "../tables";
5
+
6
+ export const logQuery = defineQueryHandler({
7
+ name: "log",
8
+ schema: z.object({
9
+ limit: z.number().min(1).max(100).default(50),
10
+ }),
11
+ access: { roles: ["Admin", "SystemAdmin"] },
12
+ handler: async (query, ctx) => {
13
+ const rows = await ctx.db
14
+ .select()
15
+ .from(deliveryAttemptsTable)
16
+ .orderBy(desc(deliveryAttemptsTable.createdAt))
17
+ .limit(query.payload.limit);
18
+
19
+ return { rows };
20
+ },
21
+ });
@@ -0,0 +1,18 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { notificationPreferencesTable } from "../tables";
5
+
6
+ export const preferencesQuery = defineQueryHandler({
7
+ name: "preferences",
8
+ schema: z.object({}),
9
+ access: { openToAll: true },
10
+ handler: async (query, ctx) => {
11
+ const rows = await ctx.db
12
+ .select()
13
+ .from(notificationPreferencesTable)
14
+ .where(eq(notificationPreferencesTable.userId, query.user.id));
15
+
16
+ return { rows };
17
+ },
18
+ });
@@ -0,0 +1,28 @@
1
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { upsertPreference } from "../upsert-preference";
4
+
5
+ export const setPreferenceWrite = defineWriteHandler({
6
+ name: "setPreference",
7
+ schema: z.object({
8
+ notificationType: z.string(), // qualified name or "*"
9
+ channel: z.string(), // "inApp", "email", etc. or "*"
10
+ enabled: z.boolean(),
11
+ }),
12
+ // Every user manages their own preferences; tenant+user scoping is on the WHERE.
13
+ access: { openToAll: true },
14
+ handler: async (event, ctx) => {
15
+ const { notificationType, channel, enabled } = event.payload;
16
+ const { id: userId, tenantId } = event.user;
17
+
18
+ const result = await upsertPreference(ctx.db, event.user, {
19
+ tenantId,
20
+ userId,
21
+ notificationType,
22
+ channel,
23
+ enabled,
24
+ });
25
+ if (!result.isSuccess) return result;
26
+ return { isSuccess: true, data: { notificationType, channel, enabled } };
27
+ },
28
+ });