@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
@@ -1,13 +1,13 @@
1
- // Boot-/Seed-Helper für App-Authors. Schreibt System-Templates (tenantId=
2
- // SYSTEM_TENANT_ID, scope=system, status=active) idempotent in die DB —
3
- // gleicher Projection-Pfad wie upsertSystem-Handler, ohne Access-Check.
1
+ // Boot/seed helper for app authors. Writes system templates (tenantId=
2
+ // SYSTEM_TENANT_ID, scope=system, status=active) idempotently — same projection
3
+ // path as the upsertSystem handler, without the access check.
4
4
  //
5
- // **Wann nutzen?** runProdApp-seeds / TenantCreated-Hooks die Welcome-,
6
- // Incident- oder Feature-Mail-Slugs installieren, damit renderer-simple
7
- // (oder mail-html) sie per template-resolver auflösen kann.
5
+ // Use it from runProdApp seeds or TenantCreated hooks that install the welcome,
6
+ // incident or feature mail slugs, so the mail renderer can resolve them
7
+ // through the template-resolver.
8
8
  //
9
- // Default ifExists="skip". `createSystemUser(SYSTEM_TENANT_ID)` als Actor —
10
- // bewusst nicht TestUsers (Prod-Seeds ≠ Test-Utilities).
9
+ // Default ifExists="skip". The actor is `createSystemUser(SYSTEM_TENANT_ID)`,
10
+ // deliberately not TestUsers (prod seeds are not test utilities).
11
11
 
12
12
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import {
@@ -19,6 +19,7 @@ import {
19
19
  } from "@cosmicdrift/kumiko-framework/db";
20
20
  import {
21
21
  createSystemUser,
22
+ type Registry,
22
23
  type SessionUser,
23
24
  SYSTEM_TENANT_ID,
24
25
  type TenantId,
@@ -30,7 +31,12 @@ import {
30
31
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
31
32
  import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants.js";
32
33
  import { executor } from "./handlers/shared.js";
34
+ import { evaluateTemplateWrite } from "./handlers/template-gates.js";
35
+ import { TEMPLATE_RESOLVER_FEATURE } from "./qualified-names.js";
33
36
  import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
37
+ import type { TemplateBundle } from "./template-bundle.js";
38
+ import { templateSlugSchema } from "./template-meta.js";
39
+ import { variablesFromSchema } from "./template-validation.js";
34
40
 
35
41
  /** Drop projection orphans (no events anywhere) so boot seeds can recreate. */
36
42
  async function resolveExistingForEventStoreSeed(
@@ -65,7 +71,10 @@ export type SeedSystemTemplateOptions = {
65
71
  readonly content: string;
66
72
  readonly contentFormat: ContentFormat;
67
73
  readonly variableSchema?: Record<string, unknown>;
74
+ /** Omitted: links on an existing row stay untouched, so a re-seed on boot keeps images a
75
+ * system admin uploaded through resource-upload. */
68
76
  readonly linkedResources?: Record<string, string>;
77
+ readonly meta?: unknown;
69
78
  readonly parentTemplateId?: string | null;
70
79
  readonly by?: SessionUser;
71
80
  readonly ifExists?: SeedIfExists;
@@ -75,10 +84,29 @@ export async function seedSystemTemplate(
75
84
  db: DbConnection,
76
85
  opts: SeedSystemTemplateOptions,
77
86
  ): Promise<{ id: string }> {
78
- const tenantId = SYSTEM_TENANT_ID as TenantId;
87
+ const tenantId = SYSTEM_TENANT_ID;
79
88
  const by = opts.by ?? createSystemUser(tenantId);
80
89
  const tdb = createTenantDb(db, tenantId, "system");
81
90
 
91
+ const label = `seedSystemTemplate ${opts.slug}/${opts.kind}/${opts.locale}`;
92
+ const slug = templateSlugSchema.safeParse(opts.slug);
93
+ if (!slug.success) throw new Error(`${label} rejected: ${slug.error.issues[0]?.message}`);
94
+ const evaluated = evaluateTemplateWrite({
95
+ kind: opts.kind,
96
+ scope: "system",
97
+ contentFormat: opts.contentFormat,
98
+ content: opts.content,
99
+ rawMeta: opts.meta,
100
+ variables: variablesFromSchema(opts.variableSchema ?? {}),
101
+ resourceKeys: Object.keys(opts.linkedResources ?? {}),
102
+ });
103
+ const issues = evaluated.ok ? evaluated.publishIssues : evaluated.issues;
104
+ if (!evaluated.ok || issues.length > 0) {
105
+ throw new Error(
106
+ `${label} rejected: ${issues.map((issue) => `${issue.code} (${issue.params?.["detail"] ?? issue.path})`).join("; ")}`,
107
+ );
108
+ }
109
+
82
110
  const existingRow = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
83
111
  tenantId,
84
112
  slug: opts.slug,
@@ -93,17 +121,20 @@ export async function seedSystemTemplate(
93
121
  );
94
122
 
95
123
  const variableSchema = JSON.stringify(opts.variableSchema ?? {});
96
- const linkedResources = JSON.stringify(opts.linkedResources ?? {});
124
+ const meta = evaluated.metaJson;
125
+ const linkedResources =
126
+ opts.linkedResources === undefined ? undefined : JSON.stringify(opts.linkedResources);
97
127
  const parentTemplateId = opts.parentTemplateId ?? null;
98
128
 
99
129
  const rowFields = {
100
130
  slug: opts.slug,
101
131
  kind: opts.kind,
102
132
  locale: opts.locale,
103
- content: opts.content,
133
+ content: evaluated.content,
104
134
  contentFormat: opts.contentFormat,
105
135
  variableSchema,
106
- linkedResources,
136
+ ...(linkedResources !== undefined && { linkedResources }),
137
+ meta,
107
138
  title: null,
108
139
  folder: null,
109
140
  scope: "system" as const,
@@ -111,7 +142,22 @@ export async function seedSystemTemplate(
111
142
  status: "active" as const,
112
143
  };
113
144
 
114
- const applyUpdate = async (row: { id: string; version: number }): Promise<{ id: string }> => {
145
+ const applyUpdate = async (
146
+ row: { id: string; version: number },
147
+ current: TemplateResourceRow | null | undefined,
148
+ ): Promise<{ id: string }> => {
149
+ // Skip no-op updates so re-seeding on every pod boot does not bump
150
+ // version/modifiedAt or grow the event store.
151
+ if (
152
+ current?.content === rowFields.content &&
153
+ current?.contentFormat === rowFields.contentFormat &&
154
+ current?.title === rowFields.title &&
155
+ current?.variableSchema === rowFields.variableSchema &&
156
+ current?.meta === rowFields.meta &&
157
+ (linkedResources === undefined || current?.linkedResources === linkedResources)
158
+ ) {
159
+ return { id: String(row.id) };
160
+ }
115
161
  const result = await executor.update(
116
162
  { id: row.id, version: row.version, changes: rowFields },
117
163
  by,
@@ -127,7 +173,11 @@ export async function seedSystemTemplate(
127
173
  existing,
128
174
  ifExists: opts.ifExists,
129
175
  create: async () => {
130
- const result = await executor.create({ ...rowFields, tenantId }, by, tdb);
176
+ const result = await executor.create(
177
+ { ...rowFields, linkedResources: linkedResources ?? "{}", tenantId },
178
+ by,
179
+ tdb,
180
+ );
131
181
  if (!result.isSuccess) {
132
182
  // Parallel boot pods can both delete the orphan and race on create —
133
183
  // unique on (tenantId, slug, kind, locale) → resolve winner and update.
@@ -152,7 +202,7 @@ export async function seedSystemTemplate(
152
202
  existing: resolved,
153
203
  ifExists: opts.ifExists,
154
204
  create: () => Promise.reject(new Error("seedSystemTemplate: unreachable create")),
155
- update: applyUpdate,
205
+ update: (row) => applyUpdate(row, again),
156
206
  });
157
207
  }
158
208
  throw new Error(`seedSystemTemplate create failed: ${JSON.stringify(result)}`);
@@ -163,10 +213,90 @@ export async function seedSystemTemplate(
163
213
  }
164
214
  return { id: String(data.id) };
165
215
  },
166
- update: applyUpdate,
216
+ update: (row) => applyUpdate(row, existingRow),
167
217
  });
168
218
  }
169
219
 
220
+ export type SeedSystemTemplateBundleOptions = {
221
+ readonly by?: SessionUser;
222
+ readonly ifExists?: SeedIfExists;
223
+ };
224
+
225
+ // Seeds one system row per bundle locale. Bundled resources need a file
226
+ // provider and the system tenant's file space, which a boot seed does not
227
+ // have, so those go through the import-bundle-system write instead.
228
+ export async function seedSystemTemplateBundle(
229
+ db: DbConnection,
230
+ bundle: TemplateBundle,
231
+ opts: SeedSystemTemplateBundleOptions = {},
232
+ ): Promise<{ ids: Record<string, string> }> {
233
+ if (Object.keys(bundle.resources).length > 0) {
234
+ throw new Error(
235
+ `seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}: bundles with resources cannot be seeded; use the import-bundle-system write`,
236
+ );
237
+ }
238
+ const ids: Record<string, string> = {};
239
+ for (const [locale, entry] of Object.entries(bundle.locales)) {
240
+ const seeded = await seedSystemTemplate(db, {
241
+ slug: bundle.slug,
242
+ kind: bundle.kind,
243
+ locale,
244
+ content: entry.body,
245
+ contentFormat: bundle.contentFormat,
246
+ variableSchema: bundle.variableSchema ?? {},
247
+ meta: entry.meta,
248
+ ...(opts.by !== undefined ? { by: opts.by } : {}),
249
+ ...(opts.ifExists !== undefined ? { ifExists: opts.ifExists } : {}),
250
+ });
251
+ ids[locale] = seeded.id;
252
+ }
253
+ return { ids };
254
+ }
255
+
256
+ export function hasNotificationTemplates(registry: Registry): boolean {
257
+ return Array.from(registry.getAllNotifications().values()).some(
258
+ (notification) => notification.template !== undefined,
259
+ );
260
+ }
261
+
262
+ // Boot seed for notifications that ship their mail text with the code
263
+ // (`r.notification({ template, meta, variables })`). The code is authoritative,
264
+ // so existing system rows are updated; tenant overrides are untouched.
265
+ export async function seedNotificationTemplates(
266
+ db: DbConnection,
267
+ registry: Registry,
268
+ ): Promise<void> {
269
+ const hasTemplates = hasNotificationTemplates(registry);
270
+ if (hasTemplates && registry.getFeature(TEMPLATE_RESOLVER_FEATURE) === undefined) {
271
+ throw new Error(
272
+ `Notifications declare a mail template but the "${TEMPLATE_RESOLVER_FEATURE}" feature is not mounted`,
273
+ );
274
+ }
275
+ if (hasTemplates) {
276
+ // Dynamic: renderer-foundation imports this feature's barrel, a static import would cycle.
277
+ const { collectRendererPlugins } = await import("../renderer-foundation/index.js");
278
+ if (!collectRendererPlugins(registry).some((plugin) => plugin.kinds.includes("mail"))) {
279
+ throw new Error(
280
+ 'Notifications declare a mail template but no renderer plugin for kind "mail" is registered (mount renderer-foundation and @cosmicdrift/kumiko-renderer-mail-html)',
281
+ );
282
+ }
283
+ }
284
+ for (const [slug, notification] of registry.getAllNotifications()) {
285
+ for (const [locale, { body }] of Object.entries(notification.template ?? {})) {
286
+ await seedSystemTemplate(db, {
287
+ slug,
288
+ kind: "mail",
289
+ locale,
290
+ content: body,
291
+ contentFormat: "html",
292
+ meta: notification.meta?.[locale],
293
+ variableSchema: notification.variables,
294
+ ifExists: "update",
295
+ });
296
+ }
297
+ }
298
+ }
299
+
170
300
  export type SeedTextBlockOptions = {
171
301
  readonly tenantId: TenantId;
172
302
  readonly slug: string;
@@ -306,7 +436,7 @@ export async function seedLegalContentFromJson(
306
436
  blocks: readonly LegalContentBlock[],
307
437
  opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
308
438
  ): Promise<void> {
309
- const tenantId = opts.tenantId ?? (SYSTEM_TENANT_ID as TenantId);
439
+ const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
310
440
  const by = opts.by ?? createSystemUser(tenantId);
311
441
  for (const block of blocks) {
312
442
  await seedTextBlock(db, {
@@ -0,0 +1,416 @@
1
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type {
3
+ ConfigAccessor,
4
+ HandlerContext,
5
+ Registry,
6
+ SessionUser,
7
+ TenantId,
8
+ WriteResult,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ NotFoundError,
12
+ UnprocessableError,
13
+ writeFailure,
14
+ } from "@cosmicdrift/kumiko-framework/errors";
15
+ import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
16
+ import { fileContextForTenant } from "../file-foundation/index.js";
17
+ import type { FileAttachmentRef } from "../renderer-foundation/index.js";
18
+ import { requireForTenant } from "../shared/index.js";
19
+ import { SYSTEM_TENANT_ID } from "./constants.js";
20
+ import {
21
+ DEFAULT_RESOURCE_MAX_BYTES,
22
+ MAX_FILE_ATTACHMENTS_PER_TEMPLATE,
23
+ RESOURCE_MAX_BYTES_CONFIG_KEY,
24
+ } from "./handlers/resource-shared.js";
25
+ import { executor } from "./handlers/shared.js";
26
+ import {
27
+ attachmentEntriesOf,
28
+ type LinkedResourceEntry,
29
+ parseLinkedResourceEntries,
30
+ templatePdfAttachmentLinkSchema,
31
+ } from "./linked-resources.js";
32
+ import { TemplateResolverErrors } from "./qualified-names.js";
33
+ import { normalizeResourceImage } from "./resource-image.js";
34
+ import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
35
+
36
+ const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
37
+ "image/png": "png",
38
+ "image/jpeg": "jpg",
39
+ "image/gif": "gif",
40
+ "image/webp": "webp",
41
+ };
42
+
43
+ export async function readTemplateResourceMaxBytes(
44
+ config: ConfigAccessor | undefined,
45
+ registry: Registry,
46
+ ): Promise<number> {
47
+ if (config === undefined || registry.getConfigKey(RESOURCE_MAX_BYTES_CONFIG_KEY) === undefined) {
48
+ return DEFAULT_RESOURCE_MAX_BYTES;
49
+ }
50
+ const value = await config(RESOURCE_MAX_BYTES_CONFIG_KEY);
51
+ return typeof value === "number" ? value : DEFAULT_RESOURCE_MAX_BYTES;
52
+ }
53
+
54
+ export type StoredTemplateResource = {
55
+ readonly fileRefId: string;
56
+ readonly key?: string;
57
+ readonly contentType: string;
58
+ readonly width: number;
59
+ readonly height: number;
60
+ readonly size: number;
61
+ };
62
+
63
+ // Normalizes the image, stores it as a file ref of the target tenant and, with templateId + key,
64
+ // links it into that tenant's template. The caller is responsible for authorization; the function trusts tenantId.
65
+ export async function storeTemplateResource(args: {
66
+ readonly ctx: HandlerContext;
67
+ readonly user: SessionUser;
68
+ readonly tenantId: TenantId;
69
+ readonly bytes: Uint8Array;
70
+ readonly maxBytes: number;
71
+ readonly templateId?: string;
72
+ readonly key?: string;
73
+ }): Promise<WriteResult<StoredTemplateResource>> {
74
+ const { ctx, user, tenantId, bytes, maxBytes, templateId, key } = args;
75
+ if (bytes.length > maxBytes) {
76
+ return writeFailure(
77
+ new UnprocessableError(TemplateResolverErrors.resourceTooLarge, {
78
+ i18nKey: "templateResolver.errors.resource.tooLarge",
79
+ details: { maxBytes, size: bytes.length },
80
+ }),
81
+ );
82
+ }
83
+ let image: Awaited<ReturnType<typeof normalizeResourceImage>>;
84
+ try {
85
+ image = await normalizeResourceImage(Buffer.from(bytes));
86
+ } catch (cause) {
87
+ return writeFailure(
88
+ new UnprocessableError(TemplateResolverErrors.resourceUnsupportedImage, {
89
+ i18nKey: "templateResolver.errors.resource.unsupportedImage",
90
+ cause: cause instanceof Error ? cause : undefined,
91
+ }),
92
+ );
93
+ }
94
+
95
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
96
+ let row: TemplateResourceRow | undefined;
97
+ if (templateId !== undefined) {
98
+ row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
99
+ tenantId,
100
+ id: templateId,
101
+ });
102
+ if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
103
+ }
104
+
105
+ const created = await createFileRefFromBytes(
106
+ {
107
+ db,
108
+ files: fileContextForTenant(ctx, tenantId),
109
+ registry: ctx.registry,
110
+ user: { ...user, tenantId },
111
+ },
112
+ {
113
+ // Neutral name: the source's file name may be personal data.
114
+ fileName: `${key ?? "resource"}.${EXTENSION_BY_CONTENT_TYPE[image.contentType] ?? "bin"}`,
115
+ mimeType: image.contentType,
116
+ data: image.data,
117
+ },
118
+ );
119
+ if (!created.isSuccess) return created;
120
+
121
+ if (row !== undefined && key !== undefined) {
122
+ // The replaced FileRef stays: copy-locale and tenant overrides can share ids and
123
+ // there is no reference count to tell when the last user is gone.
124
+ const linkedResources = {
125
+ ...parseLinkedResourceEntries(row.linkedResources),
126
+ [key]: created.data.id,
127
+ };
128
+ const updated = await executor.update(
129
+ {
130
+ id: row.id,
131
+ version: row.version,
132
+ changes: { linkedResources: JSON.stringify(linkedResources) },
133
+ },
134
+ user,
135
+ db,
136
+ { streamTenantId },
137
+ );
138
+ if (!updated.isSuccess) return updated;
139
+ }
140
+
141
+ return {
142
+ isSuccess: true as const,
143
+ data: {
144
+ fileRefId: created.data.id,
145
+ ...(key !== undefined && { key }),
146
+ contentType: image.contentType,
147
+ width: image.width,
148
+ height: image.height,
149
+ size: image.data.length,
150
+ },
151
+ };
152
+ }
153
+
154
+ const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d] as const;
155
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
156
+ const JPEG_MAGIC = [0xff, 0xd8, 0xff] as const;
157
+
158
+ const ATTACHMENT_TYPES = [
159
+ { contentType: "application/pdf", extension: "pdf", magic: PDF_MAGIC },
160
+ { contentType: "image/png", extension: "png", magic: PNG_MAGIC },
161
+ { contentType: "image/jpeg", extension: "jpg", magic: JPEG_MAGIC },
162
+ ] as const;
163
+
164
+ function detectAttachmentType(bytes: Uint8Array) {
165
+ return ATTACHMENT_TYPES.find(({ magic }) => magic.every((byte, index) => bytes[index] === byte));
166
+ }
167
+
168
+ export type StoredTemplateAttachment = {
169
+ readonly fileRefId: string;
170
+ readonly key: string;
171
+ readonly fileName: string;
172
+ readonly contentType: string;
173
+ readonly size: number;
174
+ };
175
+
176
+ type AttachmentType = NonNullable<ReturnType<typeof detectAttachmentType>>;
177
+
178
+ export function attachmentNeedsMailTemplate(): WriteResult<never> {
179
+ return writeFailure(
180
+ new UnprocessableError(TemplateResolverErrors.attachmentInvalid, {
181
+ i18nKey: "templateResolver.errors.attachment.invalid",
182
+ details: { code: "mail_only" },
183
+ }),
184
+ );
185
+ }
186
+
187
+ export function attachmentLimitFailure(): WriteResult<never> {
188
+ return writeFailure(
189
+ new UnprocessableError(TemplateResolverErrors.attachmentLimit, {
190
+ i18nKey: "templateResolver.errors.attachment.limit",
191
+ i18nParams: { max: MAX_FILE_ATTACHMENTS_PER_TEMPLATE },
192
+ details: { max: MAX_FILE_ATTACHMENTS_PER_TEMPLATE },
193
+ }),
194
+ );
195
+ }
196
+
197
+ // Files and rendered PDFs share one cap; the entry being replaced under `key` does not count.
198
+ export function otherAttachmentCount(
199
+ linked: Readonly<Record<string, LinkedResourceEntry>>,
200
+ key: string,
201
+ ): number {
202
+ return attachmentEntriesOf(linked).filter(([linkedKey]) => linkedKey !== key).length;
203
+ }
204
+
205
+ function inspectAttachmentBytes(
206
+ bytes: Uint8Array,
207
+ maxBytes: number,
208
+ ): { readonly failure: WriteResult<never> } | { readonly type: AttachmentType } {
209
+ if (bytes.length > maxBytes) {
210
+ return {
211
+ failure: writeFailure(
212
+ new UnprocessableError(TemplateResolverErrors.resourceTooLarge, {
213
+ details: { maxBytes, size: bytes.length },
214
+ }),
215
+ ),
216
+ };
217
+ }
218
+ const type = detectAttachmentType(bytes);
219
+ if (type === undefined) {
220
+ return {
221
+ failure: writeFailure(
222
+ new UnprocessableError(TemplateResolverErrors.attachmentUnsupportedType, {
223
+ i18nKey: "templateResolver.errors.attachment.unsupportedType",
224
+ }),
225
+ ),
226
+ };
227
+ }
228
+ return { type };
229
+ }
230
+
231
+ // Stores the bytes unchanged as a file of the target tenant, without linking it anywhere.
232
+ async function createAttachmentFileRef(args: {
233
+ readonly ctx: HandlerContext;
234
+ readonly user: SessionUser;
235
+ readonly tenantId: TenantId;
236
+ readonly bytes: Uint8Array;
237
+ readonly key: string;
238
+ readonly type: AttachmentType;
239
+ }): Promise<WriteResult<{ readonly id: string }>> {
240
+ const { ctx, user, tenantId, bytes, key, type } = args;
241
+ const { db } = requireForTenant(ctx, tenantId);
242
+ const created = await createFileRefFromBytes(
243
+ {
244
+ db,
245
+ files: fileContextForTenant(ctx, tenantId),
246
+ registry: ctx.registry,
247
+ user: { ...user, tenantId },
248
+ },
249
+ {
250
+ // Neutral stored name: the admin's file name may be personal data and lives in the link entry.
251
+ fileName: `${key}.${type.extension}`,
252
+ mimeType: type.contentType,
253
+ data: bytes,
254
+ },
255
+ );
256
+ return created.isSuccess ? { isSuccess: true, data: { id: created.data.id } } : created;
257
+ }
258
+
259
+ // Stores a PDF/PNG/JPEG for a bundle import: validated like an upload, linked later by the importer.
260
+ export async function storeBundleAttachment(args: {
261
+ readonly ctx: HandlerContext;
262
+ readonly user: SessionUser;
263
+ readonly tenantId: TenantId;
264
+ readonly bytes: Uint8Array;
265
+ readonly maxBytes: number;
266
+ readonly key: string;
267
+ readonly fileName: string;
268
+ }): Promise<WriteResult<FileAttachmentRef>> {
269
+ const inspected = inspectAttachmentBytes(args.bytes, args.maxBytes);
270
+ if ("failure" in inspected) return inspected.failure;
271
+ const created = await createAttachmentFileRef({ ...args, type: inspected.type });
272
+ if (!created.isSuccess) return created;
273
+ return {
274
+ isSuccess: true,
275
+ data: { kind: "file", fileRefId: created.data.id, fileName: args.fileName },
276
+ };
277
+ }
278
+
279
+ // Stores a PDF/PNG/JPEG byte-for-byte (no re-encoding, the recipient gets what the admin uploaded)
280
+ // and links it under `key` in a mail template of the target tenant. The caller authorizes tenantId.
281
+ export async function storeTemplateAttachment(args: {
282
+ readonly ctx: HandlerContext;
283
+ readonly user: SessionUser;
284
+ readonly tenantId: TenantId;
285
+ readonly bytes: Uint8Array;
286
+ readonly maxBytes: number;
287
+ readonly templateId: string;
288
+ readonly key: string;
289
+ readonly fileName: string;
290
+ }): Promise<WriteResult<StoredTemplateAttachment>> {
291
+ const { ctx, user, tenantId, bytes, maxBytes, templateId, key, fileName } = args;
292
+ const inspected = inspectAttachmentBytes(bytes, maxBytes);
293
+ if ("failure" in inspected) return inspected.failure;
294
+ const { type } = inspected;
295
+
296
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
297
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
298
+ tenantId,
299
+ id: templateId,
300
+ });
301
+ if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
302
+ if (row.kind !== "mail") return attachmentNeedsMailTemplate();
303
+
304
+ const linked = parseLinkedResourceEntries(row.linkedResources);
305
+ if (otherAttachmentCount(linked, key) >= MAX_FILE_ATTACHMENTS_PER_TEMPLATE) {
306
+ return attachmentLimitFailure();
307
+ }
308
+
309
+ const created = await createAttachmentFileRef({ ctx, user, tenantId, bytes, key, type });
310
+ if (!created.isSuccess) return created;
311
+
312
+ const entry: LinkedResourceEntry = { kind: "file", fileRefId: created.data.id, fileName };
313
+ const updated = await executor.update(
314
+ {
315
+ id: row.id,
316
+ version: row.version,
317
+ changes: { linkedResources: JSON.stringify({ ...linked, [key]: entry }) },
318
+ },
319
+ user,
320
+ db,
321
+ { streamTenantId },
322
+ );
323
+ if (!updated.isSuccess) return updated;
324
+
325
+ return {
326
+ isSuccess: true as const,
327
+ data: {
328
+ fileRefId: created.data.id,
329
+ key,
330
+ fileName,
331
+ contentType: type.contentType,
332
+ size: bytes.length,
333
+ },
334
+ };
335
+ }
336
+
337
+ export type LinkedTemplatePdfAttachment = {
338
+ readonly key: string;
339
+ readonly templateSlug: string;
340
+ readonly fileName: string;
341
+ };
342
+
343
+ function pdfAttachmentInvalid(code: string): WriteResult<never> {
344
+ return writeFailure(
345
+ new UnprocessableError(TemplateResolverErrors.attachmentInvalid, {
346
+ i18nKey: "templateResolver.errors.attachment.invalid",
347
+ details: { code },
348
+ }),
349
+ );
350
+ }
351
+
352
+ // Links a PDF that is rendered from a document template at send time into a mail template of the
353
+ // target tenant. Nothing is stored besides the link entry. The caller authorizes tenantId.
354
+ export async function linkTemplatePdfAttachment(args: {
355
+ readonly ctx: HandlerContext;
356
+ readonly user: SessionUser;
357
+ readonly tenantId: TenantId;
358
+ readonly templateId: string;
359
+ readonly key: string;
360
+ readonly templateSlug: string;
361
+ readonly fileName: string;
362
+ }): Promise<WriteResult<LinkedTemplatePdfAttachment>> {
363
+ const { ctx, user, tenantId, templateId, key } = args;
364
+ const link = templatePdfAttachmentLinkSchema.safeParse({
365
+ kind: "rendered-pdf",
366
+ templateSlug: args.templateSlug,
367
+ fileName: args.fileName,
368
+ });
369
+ if (!link.success) return pdfAttachmentInvalid("invalid_pdf_link");
370
+
371
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
372
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
373
+ tenantId,
374
+ id: templateId,
375
+ });
376
+ if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
377
+ if (row.kind !== "mail") return attachmentNeedsMailTemplate();
378
+
379
+ const linked = parseLinkedResourceEntries(row.linkedResources);
380
+ if (otherAttachmentCount(linked, key) >= MAX_FILE_ATTACHMENTS_PER_TEMPLATE) {
381
+ return attachmentLimitFailure();
382
+ }
383
+
384
+ const visibleTemplates = await selectMany<TemplateResourceRow>(
385
+ db,
386
+ templateResourcesTable,
387
+ { tenantId: [tenantId, SYSTEM_TENANT_ID], slug: link.data.templateSlug, kind: "document-pdf" },
388
+ { limit: 1 },
389
+ );
390
+ if (visibleTemplates.length === 0) {
391
+ return writeFailure(
392
+ new UnprocessableError(TemplateResolverErrors.attachmentPdfTemplateMissing, {
393
+ i18nKey: "templateResolver.errors.attachment.pdfTemplateMissing",
394
+ i18nParams: { slug: link.data.templateSlug },
395
+ details: { templateSlug: link.data.templateSlug },
396
+ }),
397
+ );
398
+ }
399
+
400
+ const updated = await executor.update(
401
+ {
402
+ id: row.id,
403
+ version: row.version,
404
+ changes: { linkedResources: JSON.stringify({ ...linked, [key]: link.data }) },
405
+ },
406
+ user,
407
+ db,
408
+ { streamTenantId },
409
+ );
410
+ if (!updated.isSuccess) return updated;
411
+
412
+ return {
413
+ isSuccess: true as const,
414
+ data: { key, templateSlug: link.data.templateSlug, fileName: link.data.fileName },
415
+ };
416
+ }