@cosmicdrift/kumiko-samples 0.387.0 → 0.389.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 (274) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
  6. package/packages/bundled-features/src/audit/changes.json +12 -0
  7. package/packages/bundled-features/src/audit/feature.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  15. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  16. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  17. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  18. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  19. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  20. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  21. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  22. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  23. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  24. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  25. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  26. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  27. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  28. package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
  29. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  30. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  32. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  33. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  37. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  39. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  40. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  51. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  52. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  53. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  55. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  57. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  58. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  59. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  60. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  61. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  62. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  63. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  64. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  65. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  66. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  67. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  68. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  69. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  70. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  71. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
  72. package/packages/bundled-features/src/cap-counter/changes.json +30 -0
  73. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  74. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  75. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  76. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  77. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  78. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  79. package/packages/bundled-features/src/config/changes.json +18 -0
  80. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  81. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  82. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  83. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  84. package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
  85. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
  86. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  87. package/packages/bundled-features/src/delivery/changes.json +42 -0
  88. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  89. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  90. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  91. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  92. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  93. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  94. package/packages/bundled-features/src/delivery/index.ts +5 -0
  95. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  96. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  97. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  98. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  99. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  100. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  101. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  102. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  103. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  104. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  105. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  106. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  107. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  108. package/packages/bundled-features/src/files/README.md +1 -1
  109. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  110. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  111. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  112. package/packages/bundled-features/src/host-routing/README.md +1 -1
  113. package/packages/bundled-features/src/host-routing/changes.json +12 -0
  114. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  115. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  116. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  118. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  120. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  122. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  123. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  124. package/packages/bundled-features/src/jobs/changes.json +13 -0
  125. package/packages/bundled-features/src/jobs/feature.ts +15 -4
  126. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  127. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  128. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  129. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  130. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  131. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  132. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  133. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  134. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  135. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  136. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  137. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  138. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  139. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  140. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  141. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  142. package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
  143. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  144. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  145. package/packages/bundled-features/src/secrets/changes.json +6 -0
  146. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  147. package/packages/bundled-features/src/sessions/changes.json +7 -0
  148. package/packages/bundled-features/src/sessions/feature.ts +8 -2
  149. package/packages/bundled-features/src/sessions/index.ts +1 -1
  150. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  151. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  152. package/packages/bundled-features/src/shared/index.ts +1 -1
  153. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  154. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  155. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  156. package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
  157. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  158. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  159. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  160. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  161. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  162. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  163. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  164. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  165. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  166. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  167. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  168. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  169. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  171. package/packages/bundled-features/src/tags/feature.ts +1 -0
  172. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  173. package/packages/bundled-features/src/template-resolver/changes.json +84 -0
  174. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  175. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  176. package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
  177. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  178. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  179. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  182. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  183. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  184. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
  185. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  186. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  187. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  188. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  189. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  190. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  191. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  192. package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
  193. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
  194. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  195. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  196. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  197. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  198. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  199. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  200. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  201. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  202. package/packages/bundled-features/src/tenant/changes.json +6 -0
  203. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  204. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  205. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  206. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  207. package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
  208. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  209. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  210. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  211. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  212. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  213. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  214. package/packages/bundled-features/src/user/feature.ts +1 -0
  215. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  216. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  217. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  218. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  219. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  220. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  221. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  222. package/packages/bundled-features/src/waitlist/changes.json +36 -0
  223. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  224. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
  226. package/packages/bundled-features/src/waitlist/import.ts +28 -3
  227. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
  228. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  229. package/packages/bundled-features/src/waitlist/options.ts +5 -1
  230. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  231. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  232. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  233. package/samples/apps/styleguide/src/app/server.ts +4 -1
  234. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  235. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  236. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  237. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  238. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  239. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  240. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  241. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  242. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  243. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  244. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  245. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  246. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  247. package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
  248. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  249. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  250. package/samples/apps/use-all-bundled/package.json +2 -1
  251. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  252. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  253. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  254. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  255. package/samples/recipes/auth-mfa/README.md +5 -0
  256. package/samples/recipes/auth-mfa/package.json +5 -2
  257. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  258. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  259. package/samples/recipes/env-composition/README.md +3 -1
  260. package/samples/recipes/env-composition/src/env.ts +2 -0
  261. package/samples/recipes/golden-path/src/feature.ts +2 -1
  262. package/samples/recipes/legal-pages/README.md +1 -1
  263. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  264. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  265. package/samples/recipes/list-table-options/README.md +50 -0
  266. package/samples/recipes/list-table-options/package.json +11 -0
  267. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  268. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  269. package/samples/recipes/rate-limiting/README.md +18 -0
  270. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  271. package/samples/recipes/state-machine/src/events.ts +3 -2
  272. package/samples/recipes/tenant-isolation/README.md +1 -0
  273. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
  274. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -2,8 +2,9 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { isRowDeliveredTo } from "../api.ts";
5
6
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
6
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
7
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
7
8
  import { isHiddenSystemDraft } from "../template-draft.ts";
8
9
  import { buildOverrideDiff } from "./override-diff.ts";
9
10
 
@@ -21,7 +22,7 @@ export const diffToSystemQuery = defineQueryHandler({
21
22
  !tenantRow ||
22
23
  tenantRow.tenantId !== query.user.tenantId ||
23
24
  tenantRow.scope !== "tenant" ||
24
- !isRowServedTo(deliveryPolicyOf(ctx.registry), query.user, tenantRow)
25
+ !(await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), query.user, tenantRow))
25
26
  ) {
26
27
  throw new NotFoundError("template-resource", query.payload.id);
27
28
  }
@@ -5,8 +5,9 @@ import {
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
8
+ import { isRowDeliveredTo } from "../api.ts";
8
9
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
9
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
10
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
10
11
  import { isHiddenSystemDraft, toWorkingRow } from "../template-draft.ts";
11
12
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
12
13
  import type { DocumentHtmlKind } from "../web/constants.ts";
@@ -50,7 +51,7 @@ export async function loadDocumentBase(
50
51
  const ownIsVisible =
51
52
  own !== undefined &&
52
53
  !isSystemOnlyTemplateHiddenFrom(user, ctx.registry, own) &&
53
- isRowServedTo(deliveryPolicyOf(ctx.registry), user, own);
54
+ (await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), user, own));
54
55
  if (own !== undefined && ownIsVisible) {
55
56
  const working = toWorkingRow(own, user.tenantId);
56
57
  return {
@@ -1,8 +1,9 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { isRowDeliveredTo } from "../api.ts";
4
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
5
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
6
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
6
7
  import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.ts";
7
8
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
8
9
 
@@ -25,7 +26,7 @@ export const findByIdQuery = defineQueryHandler({
25
26
  !stored ||
26
27
  isHiddenSystemDraft(query.user, stored) ||
27
28
  isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, stored) ||
28
- !isRowServedTo(deliveryPolicyOf(ctx.registry), query.user, stored)
29
+ !(await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), query.user, stored))
29
30
  ) {
30
31
  return null;
31
32
  }
@@ -1,5 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import * as z from "zod";
4
5
  import { filterDeliveredTo } from "../api.ts";
5
6
  import { type LayoutRegions, readLayoutRegions } from "../layout-regions.ts";
@@ -14,6 +15,7 @@ import {
14
15
  } from "../template-draft.ts";
15
16
  import { type MailLayoutMeta, readMailLayoutMeta } from "../template-meta.ts";
16
17
  import { FALLBACK_LOCALE } from "../web/constants.ts";
18
+ import { TemplateResolverErrors } from "../web/qualified-names.ts";
17
19
  import { localeSchema, templateSlugSchema } from "./shared.ts";
18
20
  import { parseStoredJson } from "./template-gates.ts";
19
21
 
@@ -66,20 +68,29 @@ function layoutFormat(row: WorkingRow): "html" | "mjml" | null {
66
68
  return row.contentFormat === "html" || row.contentFormat === "mjml" ? row.contentFormat : null;
67
69
  }
68
70
 
71
+ type EditScope = "tenant" | "system";
72
+
69
73
  function pickEditedRows(
70
74
  rows: readonly TemplateResourceRow[],
71
- user: { readonly tenantId: string; readonly roles: readonly string[] },
75
+ caller: { readonly tenantId: string; readonly roles: readonly string[] },
76
+ scope: EditScope,
72
77
  locale: string | undefined,
73
78
  ): {
74
79
  readonly tenantRow: WorkingRow | null;
75
80
  readonly systemRow: WorkingRow | null;
76
81
  } {
77
- const tenantRow = user.roles.includes("SystemAdmin")
78
- ? null
79
- : pickByLocale(
80
- rows.filter((row) => row.scope === "tenant"),
81
- locale,
82
- );
82
+ // A SystemAdmin editing the tenant layout sees the system version like a tenant does: served only, no draft.
83
+ const user =
84
+ scope === "system"
85
+ ? caller
86
+ : { ...caller, roles: caller.roles.filter((role) => role !== "SystemAdmin") };
87
+ const tenantRow =
88
+ scope === "system"
89
+ ? null
90
+ : pickByLocale(
91
+ rows.filter((row) => row.scope === "tenant"),
92
+ locale,
93
+ );
83
94
  const systemRow = pickByLocale(
84
95
  rows.filter((row) => row.scope === "system" && !isHiddenSystemDraft(user, row)),
85
96
  locale,
@@ -136,13 +147,21 @@ export const layoutSourceQuery = defineQueryHandler({
136
147
  kind: z.enum(["mail-layout", "pdf-layout"]),
137
148
  slug: templateSlugSchema.default("default"),
138
149
  locale: localeSchema.optional(),
150
+ scope: z.enum(["tenant", "system"]).optional(),
139
151
  }),
140
152
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
141
153
  description:
142
- "Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version and also receives its mjml source. Use it to load the layout editor.",
154
+ "Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version by default and also receives its mjml source; scope tenant loads the SystemAdmin's own tenant layout instead, and scope system is refused for everyone else. Use it to load the layout editor.",
143
155
  handler: async (query, ctx): Promise<LayoutSourceResponse> => {
144
156
  const { kind, slug, locale } = query.payload;
145
- const isSystemAdmin = query.user.roles.includes("SystemAdmin");
157
+ const callerIsSystemAdmin = query.user.roles.includes("SystemAdmin");
158
+ const scope = query.payload.scope ?? (callerIsSystemAdmin ? "system" : "tenant");
159
+ if (scope === "system" && !callerIsSystemAdmin) {
160
+ throw new AccessDeniedError({
161
+ i18nKey: "errors.access.denied",
162
+ details: { reason: TemplateResolverErrors.systemAdminRequired },
163
+ });
164
+ }
146
165
  const policy = deliveryPolicyOf(ctx.registry);
147
166
  const rows = await filterDeliveredTo(
148
167
  ctx.db,
@@ -150,7 +169,13 @@ export const layoutSourceQuery = defineQueryHandler({
150
169
  query.user,
151
170
  await selectMany<TemplateResourceRow>(ctx.db, templateResourcesTable, { kind, slug }),
152
171
  );
153
- const { tenantRow, systemRow } = pickEditedRows(rows, query.user, locale);
154
- return buildLayoutSource({ kind, locale, isSystemAdmin, tenantRow, systemRow });
172
+ const { tenantRow, systemRow } = pickEditedRows(rows, query.user, scope, locale);
173
+ return buildLayoutSource({
174
+ kind,
175
+ locale,
176
+ isSystemAdmin: scope === "system",
177
+ tenantRow,
178
+ systemRow,
179
+ });
155
180
  },
156
181
  });
@@ -1,8 +1,9 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { filterDeliveredTo } from "../api.ts";
4
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
5
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
6
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
6
7
  import { isHiddenSystemDraft } from "../template-draft.ts";
7
8
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
8
9
  import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../web/constants.ts";
@@ -47,23 +48,21 @@ export const listQuery = defineQueryHandler({
47
48
  })) as TemplateResourceRow[];
48
49
 
49
50
  const deliveryPolicy = deliveryPolicyOf(ctx.registry);
50
- return rows
51
- .filter(
52
- (row) =>
53
- !isHiddenSystemDraft(query.user, row) &&
54
- !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row) &&
55
- isRowServedTo(deliveryPolicy, query.user, row),
56
- )
57
- .map((row) => ({
58
- id: String(row.id),
59
- tenantId: row.tenantId,
60
- slug: row.slug,
61
- kind: row.kind,
62
- locale: row.locale,
63
- scope: row.scope,
64
- status: row.status,
65
- contentFormat: row.contentFormat,
66
- modifiedAt: row.modifiedAt,
67
- }));
51
+ const candidates = rows.filter(
52
+ (row) =>
53
+ !isHiddenSystemDraft(query.user, row) &&
54
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row),
55
+ );
56
+ return (await filterDeliveredTo(ctx.db, deliveryPolicy, query.user, candidates)).map((row) => ({
57
+ id: String(row.id),
58
+ tenantId: row.tenantId,
59
+ slug: row.slug,
60
+ kind: row.kind,
61
+ locale: row.locale,
62
+ scope: row.scope,
63
+ status: row.status,
64
+ contentFormat: row.contentFormat,
65
+ modifiedAt: row.modifiedAt,
66
+ }));
68
67
  },
69
68
  });
@@ -1,5 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
3
4
  import * as z from "zod";
4
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
5
6
  import {
@@ -16,7 +17,11 @@ import {
16
17
  parseTemplateDraft,
17
18
  servedVersionOf,
18
19
  } from "../template-draft.ts";
19
- import { mayEditTemplate, type TemplateEditPolicy } from "../template-edit-policy.ts";
20
+ import {
21
+ mayEditTemplate,
22
+ type TemplateEditPolicy,
23
+ templateEditAccess,
24
+ } from "../template-edit-policy.ts";
20
25
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
21
26
  import { collectTemplateUsage, type EffectiveMailLayoutSlugs } from "../template-usage.ts";
22
27
  import {
@@ -155,6 +160,19 @@ function effectiveRowsOf(
155
160
  : null;
156
161
  }
157
162
 
163
+ export function isRowVisibleToCaller(
164
+ policy: TemplateEditPolicy,
165
+ user: Parameters<typeof isHiddenSystemDraft>[0],
166
+ registry: Parameters<typeof isSystemOnlyTemplateHiddenFrom>[1],
167
+ row: TemplateResourceRow,
168
+ ): boolean {
169
+ return (
170
+ !isHiddenSystemDraft(user, row) &&
171
+ !isSystemOnlyTemplateHiddenFrom(user, registry, row) &&
172
+ mayEditTemplate(policy, user, row)
173
+ );
174
+ }
175
+
158
176
  export function effectiveMailLayoutSlugs(
159
177
  rows: readonly TemplateResourceRow[],
160
178
  policy: TemplateDeliveryPolicy,
@@ -191,7 +209,15 @@ function splitDocumentSlug(slug: string): { feature: string; notification: strin
191
209
  : { feature: slug.slice(0, separator), notification: slug.slice(separator + 1) };
192
210
  }
193
211
 
194
- function compareBy(field: SortField, a: OverviewRow, b: OverviewRow): number {
212
+ type ShownTitle = (row: OverviewRow) => string;
213
+
214
+ function compareBy(
215
+ field: SortField,
216
+ a: OverviewRow,
217
+ b: OverviewRow,
218
+ shownTitle: ShownTitle,
219
+ ): number {
220
+ if (field === "title") return shownTitle(a).localeCompare(shownTitle(b));
195
221
  return String(a[field]).localeCompare(String(b[field]));
196
222
  }
197
223
 
@@ -338,11 +364,17 @@ function filterRows(
338
364
  search: string | undefined,
339
365
  filters: readonly OverviewFilter[],
340
366
  layoutSlug: string | undefined,
367
+ shownTitle: ShownTitle,
341
368
  ): OverviewRow[] {
342
369
  const needle = search?.trim().toLowerCase() ?? "";
343
370
  return rows.filter((row) => {
344
371
  if (layoutSlug !== undefined && row.layoutSlug !== layoutSlug) return false;
345
- if (needle !== "" && !`${row.title} ${row.slug}`.toLowerCase().includes(needle)) return false;
372
+ if (
373
+ needle !== "" &&
374
+ !`${shownTitle(row)} ${row.title} ${row.slug}`.toLowerCase().includes(needle)
375
+ ) {
376
+ return false;
377
+ }
346
378
  return filters.every((filter) => filter.value.includes(String(row[filter.field])));
347
379
  });
348
380
  }
@@ -366,7 +398,7 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
366
398
  offset: z.number().int().min(0).optional(),
367
399
  totalCount: z.boolean().optional(),
368
400
  }),
369
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
401
+ access: templateEditAccess(policy.tenantEditable),
370
402
  description:
371
403
  "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; templates of notifications registered with `systemOnly` are listed for SystemAdmins only, and a TenantAdmin sees only the templates the app lists in tenantEditable; a tenant override of a template that is not listed is not delivered, so the row shows the system version with `lockedOverride` true (SystemAdmin only); use it to browse and filter all templates at a glance.",
372
404
  handler: async (query, ctx) => {
@@ -385,11 +417,8 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
385
417
  });
386
418
  }
387
419
 
388
- const visibleRows = rows.filter(
389
- (row) =>
390
- !isHiddenSystemDraft(query.user, row) &&
391
- !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row) &&
392
- mayEditTemplate(policy, query.user, row),
420
+ const visibleRows = rows.filter((row) =>
421
+ isRowVisibleToCaller(policy, query.user, ctx.registry, row),
393
422
  );
394
423
  const deliveryPolicy = deliveryPolicyOf(ctx.registry);
395
424
  const { drafts, layoutSlugByDocument } = buildDrafts(
@@ -409,17 +438,23 @@ export function makeOverviewQuery(policy: TemplateEditPolicy) {
409
438
  ),
410
439
  )
411
440
  : rowsWithUsage;
441
+ // A seeded title can be an i18n key; search and sort must see what the admin sees.
442
+ const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
443
+ const shownTitle: ShownTitle = (row) => i18n.t(row.title, ctx.locale);
412
444
  const filtered = filterRows(
413
445
  overviewRows,
414
446
  payload.search,
415
447
  payload.filters ?? [],
416
448
  payload.layoutSlug,
449
+ shownTitle,
417
450
  );
418
451
 
419
452
  const sortField: SortField =
420
453
  payload.sort !== undefined && isSortField(payload.sort) ? payload.sort : "modifiedAt";
421
454
  const direction = (payload.sortDirection ?? "desc") === "desc" ? -1 : 1;
422
- filtered.sort((a, b) => direction * compareBy(sortField, a, b) || a.id.localeCompare(b.id));
455
+ filtered.sort(
456
+ (a, b) => direction * compareBy(sortField, a, b, shownTitle) || a.id.localeCompare(b.id),
457
+ );
423
458
 
424
459
  const offset = payload.offset ?? 0;
425
460
  return {
@@ -40,7 +40,7 @@ export function makePatchDraftWrite(policy: TemplateEditPolicy) {
40
40
  }),
41
41
  access: templateEditAccess(policy.tenantEditable),
42
42
  description:
43
- "Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
43
+ "Changes a mail or PDF document template with structured operations instead of a full rewrite and saves the result as the caller's tenant draft (never published, an active version keeps being served until publish): insertBlock after a block id (null = at the start; paragraph, heading, image, button, divider, pageBreak), replaceText inside one block (the text must occur exactly once within one formatting run and cannot contain a {{placeholder}}: a find across a formatting change fails with text_spans_formatting, a find with a placeholder with placeholder_not_replaceable, and the formatting of the replaced text is kept), removeBlock, setResource on an image block, setMeta (flat merge, null removes a key) and setVariable (adds, replaces or with null removes a variable definition; refused when a notification declares the variables). Block ids and base come from document-blocks and refer to the state that was read; if the template changed since, the call fails with version_conflict and nothing is written. A patch that removes a required placeholder is refused. A TenantAdmin may patch only templates the app lists in tenantEditable, otherwise SystemAdmin only. Returns the written row.",
44
44
  handler: async (event, ctx) => {
45
45
  const { slug, kind, locale, base, ops } = event.payload;
46
46
  const refused = refuseTemplateEdit(policy, event.user, { slug, kind });
@@ -1,5 +1,6 @@
1
1
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import { type TemplateEditPolicy, templateEditAccess } from "../template-edit-policy.ts";
3
4
  import {
4
5
  emailChannelOf,
5
6
  resolveTestMailAddress,
@@ -10,22 +11,24 @@ export type SendTestStatus =
10
11
  | { readonly canSend: true; readonly reason: null }
11
12
  | { readonly canSend: false; readonly reason: "no_channel" | "no_address" };
12
13
 
13
- export const sendTestStatusQuery = defineQueryHandler({
14
- name: "send-test-status",
15
- schema: z.object({}),
16
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
17
- description:
18
- "Tells whether send-test can reach the calling user: canSend is true when an email channel is mounted and resolves an address for the caller, otherwise reason is no_channel or no_address; the address itself is never returned.",
19
- handler: async (query, ctx): Promise<SendTestStatus> => {
20
- const channel = emailChannelOf(ctx.registry);
21
- if (channel === undefined) return { canSend: false, reason: "no_channel" };
22
- const address = await resolveTestMailAddress(
23
- channel,
24
- testMailChannelContext(ctx, query.user),
25
- query.user,
26
- );
27
- return address === null
28
- ? { canSend: false, reason: "no_address" }
29
- : { canSend: true, reason: null };
30
- },
31
- });
14
+ export function makeSendTestStatusQuery(policy: TemplateEditPolicy) {
15
+ return defineQueryHandler({
16
+ name: "send-test-status",
17
+ schema: z.object({}),
18
+ access: templateEditAccess(policy.tenantEditable),
19
+ description:
20
+ "Tells whether send-test can reach the calling user: canSend is true when an email channel is mounted and resolves an address for the caller, otherwise reason is no_channel or no_address; the address itself is never returned.",
21
+ handler: async (query, ctx): Promise<SendTestStatus> => {
22
+ const channel = emailChannelOf(ctx.registry);
23
+ if (channel === undefined) return { canSend: false, reason: "no_channel" };
24
+ const address = await resolveTestMailAddress(
25
+ channel,
26
+ testMailChannelContext(ctx, query.user),
27
+ query.user,
28
+ );
29
+ return address === null
30
+ ? { canSend: false, reason: "no_address" }
31
+ : { canSend: true, reason: null };
32
+ },
33
+ });
34
+ }
@@ -8,6 +8,11 @@ import {
8
8
  TENANT_OVERRIDES_REASON,
9
9
  } from "./tenant-override-selection.ts";
10
10
 
11
+ type OverrideCountRow = Pick<
12
+ TemplateResourceRow,
13
+ "tenantId" | "slug" | "kind" | "status" | "version"
14
+ >;
15
+
11
16
  export const tenantOverrideCountsResultSchema = z.object({
12
17
  totalTenants: z.number().nullable(),
13
18
  counts: z.array(z.object({ slug: z.string(), kind: z.string(), activeOverrides: z.number() })),
@@ -21,18 +26,17 @@ export const tenantOverrideCountsQuery = defineQueryHandler({
21
26
  "Counts, per template slug and kind, the enabled tenants that override it with an active version, next to the number of enabled tenants; one query for the whole template overview instead of one tenant-overrides call per row.",
22
27
  escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
23
28
  handler: async (_query, ctx) => {
24
- const found = await selectMany<TemplateResourceRow>(
29
+ const found = await selectMany<OverrideCountRow>(
25
30
  ctx.db.unsafeRaw(),
26
31
  templateResourcesTable,
27
- {
28
- scope: "tenant",
29
- },
32
+ { scope: "tenant" },
33
+ { fields: ["tenantId", "slug", "kind", "status", "version"] },
30
34
  );
31
35
  const enabledTenantIds = await loadEnabledTenantIds(ctx);
32
36
 
33
37
  const rowsBySlugAndKind = new Map<
34
38
  string,
35
- { slug: string; kind: string; rows: TemplateResourceRow[] }
39
+ { slug: string; kind: string; rows: OverrideCountRow[] }
36
40
  >();
37
41
  for (const row of found) {
38
42
  const key = `${row.kind}|${row.slug}`;
@@ -21,8 +21,10 @@ export async function loadEnabledTenantIds(
21
21
  return new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})));
22
22
  }
23
23
 
24
+ export type OverrideCandidate = Pick<TemplateResourceRow, "tenantId" | "status" | "version">;
25
+
24
26
  // One row per tenant: its active override wins over a draft, then the newest version.
25
- function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
27
+ function isBetterRow(candidate: OverrideCandidate, current: OverrideCandidate): boolean {
26
28
  if ((candidate.status === "active") !== (current.status === "active")) {
27
29
  return candidate.status === "active";
28
30
  }
@@ -30,18 +32,18 @@ function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRo
30
32
  }
31
33
 
32
34
  function isCountedTenantRow(
33
- row: TemplateResourceRow,
35
+ row: OverrideCandidate,
34
36
  enabledTenantIds: ReadonlySet<string> | null,
35
37
  ): boolean {
36
38
  if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
37
39
  return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
38
40
  }
39
41
 
40
- export function pickBestRowPerTenant(
41
- found: readonly TemplateResourceRow[],
42
+ export function pickBestRowPerTenant<TRow extends OverrideCandidate>(
43
+ found: readonly TRow[],
42
44
  enabledTenantIds: ReadonlySet<string> | null,
43
- ): Map<string, TemplateResourceRow> {
44
- const bestByTenant = new Map<string, TemplateResourceRow>();
45
+ ): Map<string, TRow> {
46
+ const bestByTenant = new Map<string, TRow>();
45
47
  for (const row of found) {
46
48
  if (!isCountedTenantRow(row, enabledTenantIds)) continue;
47
49
  const current = bestByTenant.get(row.tenantId);
@@ -1,5 +1,9 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
- import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ defineQueryHandler,
4
+ MAX_LIST_LIMIT,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
3
7
  import * as z from "zod";
4
8
  import { TenantQueries } from "../../tenant/index.ts";
5
9
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
@@ -18,8 +22,39 @@ const directorySchema = z.object({
18
22
 
19
23
  type OverrideStatus = "active" | "draft";
20
24
 
21
- function labelsOf(raw: unknown): ReadonlyMap<string, string> {
22
- return new Map(directorySchema.parse(raw).rows.map((row) => [row.id, row.label]));
25
+ function chunksOf<T>(items: readonly T[], size: number): T[][] {
26
+ const chunks: T[][] = [];
27
+ for (let start = 0; start < items.length; start += size) {
28
+ chunks.push(items.slice(start, start + size));
29
+ }
30
+ return chunks;
31
+ }
32
+
33
+ type DirectoryQueryContext = {
34
+ readonly query: (name: string, payload: { ids: string[] }) => Promise<unknown>;
35
+ };
36
+
37
+ // Looks up only the ids the overrides need, so labels never depend on the directory's default cap.
38
+ async function labelsFor(
39
+ ctx: DirectoryQueryContext,
40
+ queryName: string,
41
+ ids: ReadonlySet<string>,
42
+ ): Promise<ReadonlyMap<string, string>> {
43
+ const labels = new Map<string, string>();
44
+ for (const chunk of chunksOf([...ids], MAX_LIST_LIMIT)) {
45
+ const raw = await ctx.query(queryName, { ids: chunk });
46
+ for (const row of directorySchema.parse(raw).rows) labels.set(row.id, row.label);
47
+ }
48
+ return labels;
49
+ }
50
+
51
+ function changerIdsOf(rows: readonly TemplateResourceRow[]): ReadonlySet<string> {
52
+ const ids = new Set<string>();
53
+ for (const row of rows) {
54
+ const changedById = row.modifiedById ?? row.insertedById;
55
+ if (changedById !== null) ids.add(changedById);
56
+ }
57
+ return ids;
23
58
  }
24
59
 
25
60
  function statusOf(row: TemplateResourceRow): OverrideStatus {
@@ -73,10 +108,14 @@ export const tenantOverridesQuery = defineQueryHandler({
73
108
  const bestByTenant = pickBestRowPerTenant(found, enabledTenantIds);
74
109
  const best = [...bestByTenant.values()];
75
110
  const tenantLabels = hasTenantFeature
76
- ? labelsOf(await ctx.query(TenantQueries.tenantDirectory, {}))
111
+ ? await labelsFor(
112
+ ctx,
113
+ TenantQueries.tenantDirectory,
114
+ new Set(best.map((row) => row.tenantId)),
115
+ )
77
116
  : new Map<string, string>();
78
117
  const userLabels = hasTenantFeature
79
- ? labelsOf(await ctx.query(TenantQueries.memberDirectory, {}))
118
+ ? await labelsFor(ctx, TenantQueries.memberDirectory, changerIdsOf(best))
80
119
  : new Map<string, string>();
81
120
 
82
121
  const rows = best
@@ -3,35 +3,46 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
5
5
  import { deliveryPolicyOf } from "../template-delivery-policy.ts";
6
+ import type { TemplateEditPolicy } from "../template-edit-policy.ts";
6
7
  import { templateSlugSchema } from "../template-meta.ts";
7
8
  import { collectTemplateUsage } from "../template-usage.ts";
8
9
  import { TEMPLATE_KINDS } from "../web/constants.ts";
9
- import { effectiveMailLayoutSlugs } from "./overview.query.ts";
10
+ import { effectiveMailLayoutSlugs, isRowVisibleToCaller } from "./overview.query.ts";
10
11
 
11
12
  const MAIL_ROW_LIMIT = 5000;
12
13
 
13
- export const usageQuery = defineQueryHandler({
14
- name: "usage",
15
- schema: z.object({ slug: templateSlugSchema, kind: z.enum(TEMPLATE_KINDS) }),
16
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
17
- description:
18
- "Lists the code-registered notifications that use one template: a mail template is used by the notification whose qualified name is its slug and that has a code template, a mail layout by every mail notification whose effective template (own override, else system) names it, or whose code meta does when no stored template exists, or that falls back to the default layout; use it to show what a change to a template affects.",
19
- handler: async (query, ctx) => {
20
- const { slug, kind } = query.payload;
21
- if (kind !== "mail-layout") return { usedBy: collectTemplateUsage(ctx.registry, slug, kind) };
22
- const mailRows = await selectMany<TemplateResourceRow>(
23
- ctx.db,
24
- templateResourcesTable,
25
- { kind: "mail" },
26
- { limit: MAIL_ROW_LIMIT },
27
- );
28
- return {
29
- usedBy: collectTemplateUsage(
30
- ctx.registry,
31
- slug,
32
- kind,
33
- effectiveMailLayoutSlugs(mailRows, deliveryPolicyOf(ctx.registry)),
34
- ),
35
- };
36
- },
37
- });
14
+ export function makeUsageQuery(policy: TemplateEditPolicy) {
15
+ return defineQueryHandler({
16
+ name: "usage",
17
+ schema: z.object({ slug: templateSlugSchema, kind: z.enum(TEMPLATE_KINDS) }),
18
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
19
+ description:
20
+ "Lists the code-registered notifications that use one template: a mail template is used by the notification whose qualified name is its slug and that has a code template, a mail layout by every mail notification whose effective template (own override, else system) names it, or whose code meta does when no stored template exists, or that falls back to the default layout; use it to show what a change to a template affects.",
21
+ handler: async (query, ctx) => {
22
+ const { slug, kind } = query.payload;
23
+ if (kind !== "mail-layout") return { usedBy: collectTemplateUsage(ctx.registry, slug, kind) };
24
+ const fetchedRows = await selectMany<TemplateResourceRow>(
25
+ ctx.db,
26
+ templateResourcesTable,
27
+ { kind: "mail" },
28
+ { orderBy: { col: "id", direction: "asc" }, limit: MAIL_ROW_LIMIT + 1 },
29
+ );
30
+ if (fetchedRows.length > MAIL_ROW_LIMIT) {
31
+ ctx.log?.warn("template usage: row limit reached, result is incomplete", {
32
+ limit: MAIL_ROW_LIMIT,
33
+ });
34
+ }
35
+ const visibleRows = fetchedRows
36
+ .slice(0, MAIL_ROW_LIMIT)
37
+ .filter((row) => isRowVisibleToCaller(policy, query.user, ctx.registry, row));
38
+ return {
39
+ usedBy: collectTemplateUsage(
40
+ ctx.registry,
41
+ slug,
42
+ kind,
43
+ effectiveMailLayoutSlugs(visibleRows, deliveryPolicyOf(ctx.registry)),
44
+ ),
45
+ };
46
+ },
47
+ });
48
+ }
@@ -6,10 +6,11 @@ import {
6
6
  SYSTEM_TENANT_ID,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import * as z from "zod";
9
+ import { isRowDeliveredTo } from "../api.ts";
9
10
  import type { TemplateWriteScope } from "../layout-regions.ts";
10
11
  import { imageResourceKeysOf } from "../linked-resources.ts";
11
12
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
12
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
13
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
13
14
  import { isSystemAdmin } from "../template-draft.ts";
14
15
  import { resolveTemplateVariables, systemPlaceholdersFor } from "../template-validation.ts";
15
16
  import type { ContentFormat, UpsertKind } from "../web/constants.ts";
@@ -69,7 +70,11 @@ export async function knownResourceKeysFor(
69
70
  locale,
70
71
  })),
71
72
  ...(scope === "tenant" &&
72
- isRowServedTo(deliveryPolicyOf(ctx.registry), user, { tenantId: user.tenantId, kind, slug })
73
+ (await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), user, {
74
+ tenantId: user.tenantId,
75
+ kind,
76
+ slug,
77
+ }))
73
78
  ? await knownResourceKeys(ctx.db, { tenantId: user.tenantId, slug, kind, scope, locale })
74
79
  : []),
75
80
  ];