@cosmicdrift/kumiko-samples 0.362.0 → 0.363.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 (252) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +17 -6
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
  5. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
  6. package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
  7. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
  8. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +14 -0
  10. package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
  13. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
  14. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
  15. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
  17. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -12
  18. package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
  20. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
  21. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
  22. package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
  23. package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
  24. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
  25. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
  26. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
  27. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
  28. package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
  29. package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +38 -44
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +115 -0
  33. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
  34. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +30 -30
  35. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
  36. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
  37. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -2
  38. package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
  39. package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
  40. package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
  41. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -2
  42. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
  43. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +3 -6
  44. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  45. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
  46. package/packages/bundled-features/src/channel-email/changes.json +19 -0
  47. package/packages/bundled-features/src/channel-email/email-channel.ts +122 -29
  48. package/packages/bundled-features/src/channel-email/feature.ts +6 -3
  49. package/packages/bundled-features/src/channel-email/index.ts +7 -1
  50. package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
  51. package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
  52. package/packages/bundled-features/src/channel-email/types.ts +23 -0
  53. package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
  54. package/packages/bundled-features/src/config/handlers/set.write.ts +1 -5
  55. package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
  56. package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
  58. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  59. package/packages/bundled-features/src/data-retention/index.ts +1 -0
  60. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
  61. package/packages/bundled-features/src/delivery/attachment-ref.ts +84 -0
  62. package/packages/bundled-features/src/delivery/changes.json +48 -0
  63. package/packages/bundled-features/src/delivery/channel-context.ts +129 -4
  64. package/packages/bundled-features/src/delivery/constants.ts +2 -0
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
  66. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  67. package/packages/bundled-features/src/delivery/index.ts +17 -2
  68. package/packages/bundled-features/src/delivery/jobs.ts +48 -5
  69. package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
  70. package/packages/bundled-features/src/delivery/testing.ts +9 -1
  71. package/packages/bundled-features/src/delivery/types.ts +28 -18
  72. package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
  73. package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
  74. package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
  75. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
  76. package/packages/bundled-features/src/file-foundation/index.ts +7 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +86 -0
  78. package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
  79. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
  80. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
  82. package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
  83. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
  85. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
  89. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
  90. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
  91. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
  92. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +40 -25
  93. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
  94. package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
  95. package/packages/bundled-features/src/ledger/recurring.ts +1 -3
  96. package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
  97. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  98. package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
  99. package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
  100. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
  101. package/packages/bundled-features/src/page-render/branding.ts +3 -0
  102. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  103. package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
  104. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
  105. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
  106. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
  107. package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
  108. package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
  109. package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
  110. package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
  111. package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
  112. package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
  113. package/packages/bundled-features/src/renderer-foundation/types.ts +132 -35
  114. package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
  115. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
  116. package/packages/bundled-features/src/sessions/feature.ts +2 -4
  117. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
  118. package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
  119. package/packages/bundled-features/src/shared/index.ts +6 -0
  120. package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
  121. package/packages/bundled-features/src/shared/mail-template-body.ts +25 -0
  122. package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
  123. package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
  124. package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
  125. package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
  126. package/packages/bundled-features/src/template-resolver/README.md +63 -7
  127. package/packages/bundled-features/src/template-resolver/api.ts +83 -8
  128. package/packages/bundled-features/src/template-resolver/changes.json +118 -0
  129. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +214 -0
  130. package/packages/bundled-features/src/template-resolver/constants.ts +39 -11
  131. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
  132. package/packages/bundled-features/src/template-resolver/document-schema.ts +323 -0
  133. package/packages/bundled-features/src/template-resolver/feature.ts +56 -6
  134. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
  135. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
  136. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +142 -0
  137. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +72 -0
  138. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +83 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -3
  140. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
  141. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
  142. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +108 -0
  143. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +5 -7
  144. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +250 -0
  145. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
  146. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
  147. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
  148. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
  149. package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
  150. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
  151. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +106 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
  153. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +184 -0
  155. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +183 -0
  156. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +55 -0
  157. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +57 -10
  158. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
  159. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +15 -76
  160. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +17 -0
  161. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
  162. package/packages/bundled-features/src/template-resolver/index.ts +100 -0
  163. package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
  164. package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
  165. package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
  166. package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
  167. package/packages/bundled-features/src/template-resolver/qualified-names.ts +30 -0
  168. package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
  169. package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
  170. package/packages/bundled-features/src/template-resolver/seeding.ts +147 -17
  171. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
  172. package/packages/bundled-features/src/template-resolver/table.ts +14 -0
  173. package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
  174. package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
  175. package/packages/bundled-features/src/template-resolver/template-meta.ts +129 -0
  176. package/packages/bundled-features/src/template-resolver/template-preview.ts +573 -0
  177. package/packages/bundled-features/src/template-resolver/template-resources.ts +74 -0
  178. package/packages/bundled-features/src/template-resolver/template-usage.ts +48 -0
  179. package/packages/bundled-features/src/template-resolver/template-validation.ts +229 -0
  180. package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
  181. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +9 -6
  182. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +162 -0
  183. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +28 -0
  184. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +850 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +61 -0
  186. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +100 -0
  188. package/packages/bundled-features/src/template-resolver/web/index.ts +19 -0
  189. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
  190. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +169 -0
  191. package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
  193. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
  194. package/packages/bundled-features/src/tenant/seeding.ts +41 -7
  195. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
  196. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
  197. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
  198. package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
  199. package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
  200. package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
  201. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
  202. package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
  203. package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
  204. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
  205. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
  206. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
  207. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  208. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
  209. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  210. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  211. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
  212. package/packages/bundled-features/src/waitlist/changes.json +7 -0
  213. package/packages/bundled-features/src/waitlist/feature.ts +17 -0
  214. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
  215. package/packages/bundled-features/src/waitlist/mail.ts +69 -18
  216. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +1 -2
  217. package/samples/apps/ui-walkthrough/package.json +2 -0
  218. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
  219. package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
  220. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +154 -0
  221. package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
  222. package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
  223. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +354 -0
  224. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +68 -0
  225. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
  226. package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
  227. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
  228. package/samples/apps/ui-walkthrough/src/run-config.ts +37 -1
  229. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
  230. package/samples/apps/use-all-bundled/feature-manifest.json +115 -20
  231. package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
  232. package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
  233. package/samples/apps/use-all-bundled/package.json +2 -0
  234. package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
  235. package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  237. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
  238. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
  239. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
  240. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
  241. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
  242. package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
  243. package/samples/recipes/delivery-notifications/README.md +26 -25
  244. package/samples/recipes/delivery-notifications/package.json +2 -1
  245. package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
  246. package/samples/recipes/seed-migration/README.md +1 -1
  247. package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
  248. package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
  249. package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
  250. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
  251. package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
  252. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
@@ -0,0 +1,106 @@
1
+ import { defineWriteHandler, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import * as z from "zod";
4
+ import {
5
+ collectChannels,
6
+ createEmailAttachmentReader,
7
+ EMAIL_CHANNEL_NAME,
8
+ } from "../../delivery/index.js";
9
+ import { fileContextForTenant } from "../../file-foundation/index.js";
10
+ import { TEST_MAIL_NOTIFICATION_TYPE, TEST_MAIL_SUBJECT_PREFIX } from "../constants.js";
11
+ import { TemplateResolverErrors } from "../qualified-names.js";
12
+ import { renderTemplatePreview } from "../template-preview.js";
13
+ import { localeSchema, templateSlugSchema } from "./shared.js";
14
+
15
+ const TEST_MAIL_RATE_LIMIT = { limit: 10, windowSeconds: 3600 } as const;
16
+
17
+ function emailChannelOf(registry: Registry) {
18
+ return collectChannels(registry).find((channel) => channel.name === EMAIL_CHANNEL_NAME);
19
+ }
20
+
21
+ // Transport errors can carry provider responses or the address, so the client only gets a fixed code.
22
+ function testMailFailed(code: "channel_error" | "not_sent") {
23
+ return writeFailure(
24
+ new UnprocessableError(TemplateResolverErrors.testMailFailed, {
25
+ i18nKey: "templateResolver.errors.testMail.failed",
26
+ details: { code },
27
+ }),
28
+ );
29
+ }
30
+
31
+ // The only recipient is the caller's own address, resolved server-side, so the handler can never mail a third party.
32
+ export const sendTestWrite = defineWriteHandler({
33
+ name: "send-test",
34
+ schema: z.object({
35
+ slug: templateSlugSchema,
36
+ locale: localeSchema,
37
+ content: z.string().max(200_000).optional(),
38
+ meta: z.unknown().optional(),
39
+ variableSchema: z.record(z.string(), z.unknown()).default({}),
40
+ }),
41
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
42
+ rateLimit: { per: "user+handler", ...TEST_MAIL_RATE_LIMIT },
43
+ description:
44
+ "Sends the rendered preview of a mail template draft, or of the stored template when no content is given, to the calling user's own address with the subject prefixed by [TEST], carrying the inline images and the files attached to the template; there is no recipient input, the sample values come from the declared variable examples, and a draft that fails the write rules is rejected without sending.",
45
+ handler: async (event, ctx) => {
46
+ const outcome = await renderTemplatePreview(ctx, event.user, {
47
+ ...event.payload,
48
+ kind: "mail",
49
+ });
50
+ if (!outcome.ok) return writeFailure(outcome.error);
51
+ const { preview, mailAttachments } = outcome;
52
+
53
+ const channel = emailChannelOf(ctx.registry);
54
+ if (channel === undefined) {
55
+ return writeFailure(
56
+ new UnprocessableError(TemplateResolverErrors.testMailNoChannel, {
57
+ i18nKey: "templateResolver.errors.testMail.noChannel",
58
+ }),
59
+ );
60
+ }
61
+ const channelContext = {
62
+ db: ctx.db,
63
+ registry: ctx.registry,
64
+ sseBroker: undefined,
65
+ tenantId: event.user.tenantId,
66
+ secrets: ctx.secrets,
67
+ readAttachment: createEmailAttachmentReader(
68
+ ctx.db,
69
+ ctx.registry,
70
+ event.user.tenantId,
71
+ ctx.config,
72
+ (tenantId) => fileContextForTenant(ctx, tenantId),
73
+ ),
74
+ };
75
+ const address = (await channel.resolve?.(event.user.id, channelContext)) ?? null;
76
+ if (address === null) {
77
+ return writeFailure(
78
+ new UnprocessableError(TemplateResolverErrors.testMailNoAddress, {
79
+ i18nKey: "templateResolver.errors.testMail.noAddress",
80
+ }),
81
+ );
82
+ }
83
+
84
+ const subject = `${TEST_MAIL_SUBJECT_PREFIX}${preview.subject ?? ""}`;
85
+ let sent: boolean;
86
+ try {
87
+ const result = await channel.send(
88
+ address,
89
+ {
90
+ notificationType: TEST_MAIL_NOTIFICATION_TYPE,
91
+ title: subject,
92
+ body: undefined,
93
+ data: undefined,
94
+ locale: event.payload.locale,
95
+ },
96
+ channelContext,
97
+ { subject, html: preview.html, text: preview.text ?? "", attachments: mailAttachments },
98
+ );
99
+ sent = result.status === "sent";
100
+ } catch {
101
+ return testMailFailed("channel_error");
102
+ }
103
+ if (!sent) return testMailFailed("not_sent");
104
+ return { isSuccess: true as const, data: { subject, checks: preview.checks } };
105
+ },
106
+ });
@@ -4,11 +4,15 @@ import {
4
4
  SYSTEM_TENANT_ID,
5
5
  type TenantId,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
7
8
  import * as z from "zod";
8
9
  import { requireForTenant } from "../../shared/index.js";
9
- import { TEMPLATE_KINDS, TEXT_BLOCK_KIND } from "../constants.js";
10
+ import { TEMPLATE_KINDS, TEXT_BLOCK_KIND, UPSERT_KINDS } from "../constants.js";
10
11
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
12
+ import { resolveTemplateVariables } from "../template-validation.js";
11
13
  import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared.js";
14
+ import { parseStoredJson, storedRecord } from "./template-gates.js";
15
+ import { gateTemplateWrite } from "./template-write.js";
12
16
 
13
17
  // Upsert of a single resource, one operation per (tenantId, slug, kind,
14
18
  // locale). This is the content-tree authoring path: unlike upsertTenant it
@@ -26,6 +30,9 @@ import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema }
26
30
  // upsertTenant → publish draft stage. That is the deliberate split between
27
31
  // the two authoring routes — use upsertSystem/upsertTenant when a template
28
32
  // needs a draft or a variable schema.
33
+ //
34
+ // Kinds that upsertSystem/upsertTenant also write run the same validation
35
+ // pipeline here, and because saving publishes, publish issues reject too.
29
36
  export const setWrite = defineWriteHandler({
30
37
  name: "set",
31
38
  schema: z.object({
@@ -63,17 +70,39 @@ export const setWrite = defineWriteHandler({
63
70
  locale: event.payload.locale,
64
71
  });
65
72
 
73
+ const scope = tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const);
74
+ let content = event.payload.content;
75
+ if (isOneOf(UPSERT_KINDS, kind)) {
76
+ const gated = gateTemplateWrite(
77
+ {
78
+ kind,
79
+ scope,
80
+ contentFormat: event.payload.contentFormat,
81
+ content,
82
+ rawMeta: parseStoredJson(existing?.meta ?? null),
83
+ variables: resolveTemplateVariables(
84
+ ctx.registry,
85
+ event.payload.slug,
86
+ storedRecord(existing?.variableSchema ?? null),
87
+ ),
88
+ },
89
+ "active",
90
+ );
91
+ if (!gated.ok) return gated.failure;
92
+ content = gated.evaluated.content;
93
+ }
94
+
66
95
  const fields = {
67
96
  slug: event.payload.slug,
68
97
  kind,
69
98
  locale: event.payload.locale,
70
99
  title: event.payload.title,
71
- content: event.payload.content,
100
+ content,
72
101
  contentFormat: event.payload.contentFormat,
73
102
  folder: event.payload.folder ?? null,
74
103
  variableSchema: "{}",
75
104
  linkedResources: "{}",
76
- scope: tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const),
105
+ scope,
77
106
  parentTemplateId: null,
78
107
  // Text blocks have no draft stage — saving publishes, so resolveTemplate
79
108
  // (which only returns active rows) finds them.
@@ -2,6 +2,7 @@ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import * as z from "zod";
3
3
  import { CONTENT_FORMATS, TEMPLATE_STATUSES, UPSERT_KINDS } from "../constants.js";
4
4
  import { templateResourceEntity, templateResourcesTable } from "../table.js";
5
+ import { templateSlugSchema } from "../template-meta.js";
5
6
 
6
7
  // One executor per bundle, shared by all four handlers for create/update via
7
8
  // event store + optimistic lock.
@@ -16,6 +17,8 @@ export const slugSchema = z
16
17
  .max(80)
17
18
  .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
18
19
 
20
+ export { templateSlugSchema };
21
+
19
22
  export const localeSchema = z
20
23
  .string()
21
24
  .min(2)
@@ -38,13 +41,15 @@ export const statusSchema = z.enum(TEMPLATE_STATUSES);
38
41
  // Common Upsert-Payload — geteilt zwischen upsertSystem + upsertTenant.
39
42
  // Unterschied: ACL + tenantId-Bestimmung, sonst identisch.
40
43
  export const upsertPayloadSchema = z.object({
41
- slug: slugSchema,
44
+ slug: templateSlugSchema,
42
45
  kind: kindSchema,
43
46
  locale: localeSchema,
44
47
  content: z.string().max(200_000),
45
48
  contentFormat: contentFormatSchema,
46
49
  variableSchema: z.record(z.string(), z.unknown()).default({}),
47
- linkedResources: z.record(z.string(), z.string()).default({}),
50
+ // Shape depends on kind; validated against template-meta.ts in the handler so
51
+ // problems surface as field issues on `meta.*`.
52
+ meta: z.unknown().optional(),
48
53
  parentTemplateId: z.string().min(1).optional(),
49
54
  title: titleSchema.nullable().optional(),
50
55
  folder: folderSchema.nullable().optional(),
@@ -0,0 +1,184 @@
1
+ import type { Registry, TemplateVariables } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { FieldIssue } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import {
5
+ CONTENT_FORMATS,
6
+ type ContentFormat,
7
+ TEMPLATE_KINDS,
8
+ type TemplateKind,
9
+ } from "../constants.js";
10
+ import { documentViolationsToFieldIssues } from "../document-sanitizer.js";
11
+ import { sanitizeTemplateWriteBody, type TemplateWriteScope } from "../layout-regions.js";
12
+ import { imageResourceKeysOf } from "../linked-resources.js";
13
+ import type { TemplateResourceRow } from "../table.js";
14
+ import { parseTemplateMeta, type TemplateMetaIssue } from "../template-meta.js";
15
+ import {
16
+ resolveTemplateVariables,
17
+ type TemplateValidation,
18
+ validateTemplatePlaceholders,
19
+ } from "../template-validation.js";
20
+
21
+ const PLACEHOLDER_VALIDATED_KINDS: ReadonlySet<TemplateKind> = new Set([
22
+ "mail",
23
+ "document-pdf",
24
+ "pdf-layout",
25
+ ]);
26
+
27
+ function metaIssueToFieldIssue(issue: TemplateMetaIssue): FieldIssue {
28
+ return {
29
+ path: issue.path,
30
+ code: "invalid_meta",
31
+ i18nKey: "templateResolver.errors.template.invalidMeta",
32
+ params: { detail: issue.message },
33
+ };
34
+ }
35
+
36
+ export function placeholderIssues(
37
+ validation: TemplateValidation,
38
+ opts: { readonly requireAllRequired: boolean },
39
+ ): FieldIssue[] {
40
+ const issues: FieldIssue[] = [];
41
+ if (validation.unknown.length > 0) {
42
+ issues.push({
43
+ path: "content",
44
+ code: "unknown_placeholder",
45
+ i18nKey: "templateResolver.errors.template.unknownPlaceholders",
46
+ params: { detail: validation.unknown.join(", ") },
47
+ });
48
+ }
49
+ if (opts.requireAllRequired && validation.missingRequired.length > 0) {
50
+ issues.push({
51
+ path: "content",
52
+ code: "missing_required_placeholder",
53
+ i18nKey: "templateResolver.errors.template.missingRequiredPlaceholders",
54
+ params: { detail: validation.missingRequired.join(", ") },
55
+ });
56
+ }
57
+ return issues;
58
+ }
59
+
60
+ export type TemplateWriteInput = {
61
+ readonly kind: TemplateKind;
62
+ readonly scope: TemplateWriteScope;
63
+ readonly contentFormat: ContentFormat;
64
+ readonly content: string | null;
65
+ readonly rawMeta: unknown;
66
+ readonly variables: TemplateVariables;
67
+ /** Keys with an uploaded file besides the built-in `logo`; only feeds the unknownResources warning. */
68
+ readonly resourceKeys?: readonly string[];
69
+ };
70
+
71
+ export type TemplateWriteResult =
72
+ | {
73
+ readonly ok: true;
74
+ readonly content: string | null;
75
+ readonly metaJson: string | null;
76
+ readonly validation: TemplateValidation | null;
77
+ /** Problems that still allow a draft but block publishing. */
78
+ readonly publishIssues: readonly FieldIssue[];
79
+ }
80
+ | {
81
+ readonly ok: false;
82
+ readonly issues: readonly FieldIssue[];
83
+ /** Set when the placeholder scan ran, so callers can still list what it found. */
84
+ readonly validation: TemplateValidation | null;
85
+ };
86
+
87
+ const NO_PUBLISH_ISSUES: readonly FieldIssue[] = [];
88
+
89
+ function isPlaceholderValidated(kind: TemplateKind, contentFormat: ContentFormat): boolean {
90
+ if (kind === "mail-layout") return contentFormat === "html";
91
+ return PLACEHOLDER_VALIDATED_KINDS.has(kind);
92
+ }
93
+
94
+ // The single validation pipeline of every template write: sanitize and layout
95
+ // rules, then meta, then placeholders.
96
+ export function evaluateTemplateWrite(input: TemplateWriteInput): TemplateWriteResult {
97
+ const body = sanitizeTemplateWriteBody({
98
+ kind: input.kind,
99
+ scope: input.scope,
100
+ contentFormat: input.contentFormat,
101
+ content: input.content,
102
+ });
103
+ if (!body.ok)
104
+ return {
105
+ ok: false,
106
+ issues: documentViolationsToFieldIssues(body.violations),
107
+ validation: null,
108
+ };
109
+
110
+ const parsedMeta = parseTemplateMeta(input.kind, input.rawMeta);
111
+ if (!parsedMeta.ok) {
112
+ return { ok: false, issues: parsedMeta.issues.map(metaIssueToFieldIssue), validation: null };
113
+ }
114
+ const metaJson = parsedMeta.meta === null ? null : JSON.stringify(parsedMeta.meta);
115
+ if (!isPlaceholderValidated(input.kind, input.contentFormat)) {
116
+ return {
117
+ ok: true,
118
+ content: body.content,
119
+ metaJson,
120
+ validation: null,
121
+ publishIssues: NO_PUBLISH_ISSUES,
122
+ };
123
+ }
124
+
125
+ const validation = validateTemplatePlaceholders({
126
+ kind: input.kind,
127
+ body: body.content ?? "",
128
+ meta: parsedMeta.meta,
129
+ variables: input.variables,
130
+ resourceKeys: input.resourceKeys,
131
+ });
132
+ const issues = placeholderIssues(validation, { requireAllRequired: false });
133
+ if (issues.length > 0) return { ok: false, issues, validation };
134
+ return {
135
+ ok: true,
136
+ content: body.content,
137
+ metaJson,
138
+ validation,
139
+ publishIssues: placeholderIssues(validation, { requireAllRequired: true }),
140
+ };
141
+ }
142
+
143
+ function isTemplateKind(kind: string): kind is TemplateKind {
144
+ return isOneOf(TEMPLATE_KINDS, kind);
145
+ }
146
+
147
+ export function parseStoredJson(raw: string | null): unknown {
148
+ if (!raw) return undefined;
149
+ try {
150
+ return JSON.parse(raw);
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ function isGatedKind(kind: string): kind is TemplateKind {
157
+ return isTemplateKind(kind) && (kind === "mail-layout" || PLACEHOLDER_VALIDATED_KINDS.has(kind));
158
+ }
159
+
160
+ export function storedRecord(raw: string | null): Readonly<Record<string, unknown>> {
161
+ const parsed = parseStoredJson(raw);
162
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
163
+ ? Object.fromEntries(Object.entries(parsed))
164
+ : {};
165
+ }
166
+
167
+ // Publishing makes a row servable, so a stored row must pass the same pipeline
168
+ // as an active write would have.
169
+ export function publishGateIssues(
170
+ registry: Registry,
171
+ row: TemplateResourceRow,
172
+ ): readonly FieldIssue[] {
173
+ if (!isGatedKind(row.kind) || !isOneOf(CONTENT_FORMATS, row.contentFormat)) return [];
174
+ const result = evaluateTemplateWrite({
175
+ kind: row.kind,
176
+ scope: row.scope === "system" ? "system" : "tenant",
177
+ contentFormat: row.contentFormat,
178
+ content: row.content,
179
+ rawMeta: parseStoredJson(row.meta),
180
+ variables: resolveTemplateVariables(registry, row.slug, storedRecord(row.variableSchema)),
181
+ resourceKeys: imageResourceKeysOf(row.linkedResources),
182
+ });
183
+ return result.ok ? result.publishIssues : result.issues;
184
+ }
@@ -0,0 +1,183 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ type HandlerContext,
4
+ type SessionUser,
5
+ SYSTEM_TENANT_ID,
6
+ type TemplateVariables,
7
+ type TenantId,
8
+ type WriteResult,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ AccessDeniedError,
12
+ ValidationError,
13
+ type WriteFailure,
14
+ writeFailure,
15
+ } from "@cosmicdrift/kumiko-framework/errors";
16
+ import { requireForTenant } from "../../shared/index.js";
17
+ import type { ContentFormat, TemplateKind } from "../constants.js";
18
+ import type { TemplateWriteScope } from "../layout-regions.js";
19
+ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
20
+ import type { TemplateValidation } from "../template-validation.js";
21
+ import { executor } from "./shared.js";
22
+ import {
23
+ evaluateTemplateWrite,
24
+ type TemplateWriteInput,
25
+ type TemplateWriteResult,
26
+ } from "./template-gates.js";
27
+
28
+ export type TemplateRowPayload = {
29
+ readonly slug: string;
30
+ readonly kind: TemplateKind;
31
+ readonly locale: string;
32
+ readonly content: string | null;
33
+ readonly contentFormat: ContentFormat;
34
+ readonly variableSchema: Readonly<Record<string, unknown>>;
35
+ readonly meta?: unknown;
36
+ readonly title?: string | null | undefined;
37
+ readonly folder?: string | null | undefined;
38
+ readonly parentTemplateId?: string | null | undefined;
39
+ };
40
+
41
+ export type WrittenTemplateRow = {
42
+ readonly id: string;
43
+ readonly slug: string;
44
+ readonly isNew: boolean;
45
+ readonly validation: TemplateValidation | null;
46
+ };
47
+
48
+ export type TemplateRowWrite = {
49
+ readonly ctx: HandlerContext;
50
+ readonly user: SessionUser;
51
+ readonly tenantId: TenantId;
52
+ readonly scope: TemplateWriteScope;
53
+ readonly status: "draft" | "active";
54
+ readonly variables: TemplateVariables;
55
+ readonly payload: TemplateRowPayload;
56
+ };
57
+
58
+ // The tenant-scope-under-system-tenant ban lives here so no write path can
59
+ // create the inconsistent state the resolver does not model.
60
+ export function refuseTenantScopeUnderSystemTenant(
61
+ scope: TemplateWriteScope,
62
+ tenantId: string,
63
+ ): WriteResult<never> | null {
64
+ if (scope !== "tenant" || tenantId !== SYSTEM_TENANT_ID) return null;
65
+ return writeFailure(
66
+ new AccessDeniedError({
67
+ i18nKey: "templateResolver.errors.useUpsertSystemForSystemTenant",
68
+ details: { reason: "system_tenant_override_not_allowed_use_upsert_system" },
69
+ }),
70
+ );
71
+ }
72
+
73
+ export type GatedTemplateWrite =
74
+ | { readonly ok: true; readonly evaluated: Extract<TemplateWriteResult, { ok: true }> }
75
+ | { readonly ok: false; readonly failure: WriteFailure };
76
+
77
+ // Evaluation plus the publish rule: a row that goes active must not carry
78
+ // publish issues, a draft may.
79
+ export function gateTemplateWrite(
80
+ input: TemplateWriteInput,
81
+ status: "draft" | "active",
82
+ ): GatedTemplateWrite {
83
+ const evaluated = evaluateTemplateWrite(input);
84
+ if (!evaluated.ok) {
85
+ return {
86
+ ok: false,
87
+ failure: writeFailure(new ValidationError({ fields: [...evaluated.issues] })),
88
+ };
89
+ }
90
+ if (status === "active" && evaluated.publishIssues.length > 0) {
91
+ return {
92
+ ok: false,
93
+ failure: writeFailure(new ValidationError({ fields: [...evaluated.publishIssues] })),
94
+ };
95
+ }
96
+ return { ok: true, evaluated };
97
+ }
98
+
99
+ // Shared by every template write: validate through the one pipeline, then
100
+ // create or update the (tenant, slug, kind, locale) row via the event-store
101
+ // executor. linkedResources is never part of an update; resources are attached
102
+ // separately.
103
+ export async function writeTemplateRow(
104
+ args: TemplateRowWrite,
105
+ ): Promise<WriteResult<WrittenTemplateRow>> {
106
+ const { ctx, user, tenantId, scope, status, payload } = args;
107
+ const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
108
+ if (refused !== null) return refused;
109
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
110
+
111
+ const gated = gateTemplateWrite(
112
+ {
113
+ kind: payload.kind,
114
+ scope,
115
+ contentFormat: payload.contentFormat,
116
+ content: payload.content,
117
+ rawMeta: payload.meta,
118
+ variables: args.variables,
119
+ },
120
+ status,
121
+ );
122
+ if (!gated.ok) return gated.failure;
123
+ const { evaluated } = gated;
124
+
125
+ const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
126
+ tenantId,
127
+ slug: payload.slug,
128
+ kind: payload.kind,
129
+ locale: payload.locale,
130
+ });
131
+
132
+ const fields = {
133
+ slug: payload.slug,
134
+ kind: payload.kind,
135
+ locale: payload.locale,
136
+ content: evaluated.content,
137
+ contentFormat: payload.contentFormat,
138
+ variableSchema: JSON.stringify(payload.variableSchema),
139
+ meta: evaluated.metaJson,
140
+ title: payload.title ?? null,
141
+ folder: payload.folder ?? null,
142
+ scope,
143
+ parentTemplateId: payload.parentTemplateId ?? null,
144
+ status,
145
+ };
146
+
147
+ if (existing) {
148
+ const result = await executor.update(
149
+ { id: existing.id, version: existing.version, changes: fields },
150
+ user,
151
+ db,
152
+ { streamTenantId },
153
+ );
154
+ if (!result.isSuccess) return result;
155
+ return {
156
+ isSuccess: true,
157
+ data: {
158
+ id: String(existing.id),
159
+ slug: payload.slug,
160
+ isNew: false,
161
+ validation: evaluated.validation,
162
+ },
163
+ };
164
+ }
165
+
166
+ const result = await executor.create({ ...fields, linkedResources: "{}", tenantId }, user, db, {
167
+ streamTenantId,
168
+ });
169
+ if (!result.isSuccess) return result;
170
+ // @cast-boundary db-row — executor.create returnt Record-row aus
171
+ // INSERT RETURNING; shape { id } ist garantiert weil PK in der
172
+ // Returning-Klausel ist.
173
+ const createdRow = result.data as { id: string | number };
174
+ return {
175
+ isSuccess: true,
176
+ data: {
177
+ id: String(createdRow.id),
178
+ slug: payload.slug,
179
+ isNew: true,
180
+ validation: evaluated.validation,
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,55 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
+ import * as z from "zod";
4
+ import { TenantQueries } from "../../tenant/index.js";
5
+ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
6
+ import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
7
+
8
+ const TENANT_OVERRIDES_REASON =
9
+ "SystemAdmin template overview counts how many tenants override one system template; it reads tenant-scope template rows of every tenant and returns only counts, never tenant ids or content";
10
+
11
+ const tenantIdsSchema = z.array(z.string());
12
+
13
+ type OverrideRow = Pick<TemplateResourceRow, "tenantId" | "status">;
14
+
15
+ export const tenantOverridesQuery = defineQueryHandler({
16
+ name: "tenant-overrides",
17
+ schema: z.object({
18
+ slug: templateSlugSchema,
19
+ kind: kindSchema,
20
+ locale: localeSchema.optional(),
21
+ }),
22
+ access: { roles: ["SystemAdmin"] },
23
+ description:
24
+ "Counts how many enabled tenants override one template with an active or a draft version, next to the number of enabled tenants; use it before changing a system template to see how many tenants keep their own copy. Returns counts only, no tenant ids.",
25
+ escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
26
+ handler: async (query, ctx) => {
27
+ const { slug, kind, locale } = query.payload;
28
+ const rows = await selectMany<OverrideRow>(ctx.db.unsafeRaw(), templateResourcesTable, {
29
+ slug,
30
+ kind,
31
+ scope: "tenant",
32
+ ...(locale === undefined ? {} : { locale }),
33
+ });
34
+
35
+ const hasTenantFeature =
36
+ ctx.registry.getQueryHandler(TenantQueries.activeTenantIds) !== undefined;
37
+ const enabledTenantIds = hasTenantFeature
38
+ ? new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})))
39
+ : null;
40
+
41
+ const statusByTenant = new Map<string, "active" | "draft">();
42
+ for (const row of rows) {
43
+ if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") continue;
44
+ if (enabledTenantIds !== null && !enabledTenantIds.has(row.tenantId)) continue;
45
+ if (statusByTenant.get(row.tenantId) === "active") continue;
46
+ statusByTenant.set(row.tenantId, row.status === "active" ? "active" : "draft");
47
+ }
48
+ const statuses = [...statusByTenant.values()];
49
+ return {
50
+ activeOverrides: statuses.filter((status) => status === "active").length,
51
+ draftOverrides: statuses.filter((status) => status === "draft").length,
52
+ totalTenants: enabledTenantIds === null ? null : enabledTenantIds.size,
53
+ };
54
+ },
55
+ });