@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -24,12 +24,24 @@ const PLACEHOLDER_VALIDATED_KINDS: ReadonlySet<TemplateKind> = new Set([
24
24
  "pdf-layout",
25
25
  ]);
26
26
 
27
+ // The parser's wording is English and meant for logs; the UI gets a key per setting instead.
28
+ const META_FIELDS_WITH_MESSAGES: ReadonlySet<string> = new Set([
29
+ "subject",
30
+ "previewText",
31
+ "senderName",
32
+ "replyTo",
33
+ "layoutSlug",
34
+ "fileName",
35
+ ]);
36
+
27
37
  function metaIssueToFieldIssue(issue: TemplateMetaIssue): FieldIssue {
38
+ const hasMessage = META_FIELDS_WITH_MESSAGES.has(issue.field);
28
39
  return {
29
40
  path: issue.path,
30
41
  code: "invalid_meta",
31
- i18nKey: "templateResolver.errors.template.invalidMeta",
32
- params: { detail: issue.message },
42
+ i18nKey: hasMessage
43
+ ? `templateResolver.errors.template.meta.${issue.field}.${issue.missing ? "required" : "invalid"}`
44
+ : "templateResolver.errors.template.invalidMeta",
33
45
  };
34
46
  }
35
47
 
@@ -17,6 +17,7 @@ import { requireForTenant } from "../../shared/index.js";
17
17
  import type { ContentFormat, TemplateKind } from "../constants.js";
18
18
  import type { TemplateWriteScope } from "../layout-regions.js";
19
19
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
20
+ import { activationChanges, serializeTemplateDraft } from "../template-draft.js";
20
21
  import type { TemplateValidation } from "../template-validation.js";
21
22
  import { executor } from "./shared.js";
22
23
  import {
@@ -96,6 +97,42 @@ export function gateTemplateWrite(
96
97
  return { ok: true, evaluated };
97
98
  }
98
99
 
100
+ type TemplateRowFields = {
101
+ readonly content: string | null;
102
+ readonly contentFormat: ContentFormat;
103
+ readonly variableSchema: string;
104
+ readonly meta: string | null;
105
+ readonly title: string | null;
106
+ readonly folder: string | null;
107
+ readonly parentTemplateId: string | null;
108
+ };
109
+
110
+ // A draft over an active row is stored beside it; every other write replaces the main fields.
111
+ function updateChanges(
112
+ existing: TemplateResourceRow,
113
+ status: "draft" | "active",
114
+ fields: TemplateRowFields,
115
+ ): Record<string, unknown> {
116
+ if (status === "active") return { ...fields, ...activationChanges(existing) };
117
+ if (existing.status !== "active") return { ...fields, status, draft: null };
118
+ const draft = serializeTemplateDraft({
119
+ content: fields.content,
120
+ contentFormat: fields.contentFormat,
121
+ variableSchema: fields.variableSchema,
122
+ meta: fields.meta,
123
+ title: fields.title,
124
+ folder: fields.folder,
125
+ parentTemplateId: fields.parentTemplateId,
126
+ });
127
+ return existing.activeVersion === null
128
+ ? {
129
+ draft,
130
+ activeVersion: existing.version,
131
+ activeSince: existing.modifiedAt ?? existing.insertedAt,
132
+ }
133
+ : { draft };
134
+ }
135
+
99
136
  // Shared by every template write: validate through the one pipeline, then
100
137
  // create or update the (tenant, slug, kind, locale) row via the event-store
101
138
  // executor. linkedResources is never part of an update; resources are attached
@@ -146,7 +183,11 @@ export async function writeTemplateRow(
146
183
 
147
184
  if (existing) {
148
185
  const result = await executor.update(
149
- { id: existing.id, version: existing.version, changes: fields },
186
+ {
187
+ id: existing.id,
188
+ version: existing.version,
189
+ changes: updateChanges(existing, status, fields),
190
+ },
150
191
  user,
151
192
  db,
152
193
  { streamTenantId },
@@ -163,9 +204,18 @@ export async function writeTemplateRow(
163
204
  };
164
205
  }
165
206
 
166
- const result = await executor.create({ ...fields, linkedResources: "{}", tenantId }, user, db, {
167
- streamTenantId,
168
- });
207
+ const result = await executor.create(
208
+ {
209
+ ...fields,
210
+ linkedResources: "{}",
211
+ tenantId,
212
+ draft: null,
213
+ ...(status === "active" ? activationChanges(null) : {}),
214
+ },
215
+ user,
216
+ db,
217
+ { streamTenantId },
218
+ );
169
219
  if (!result.isSuccess) return result;
170
220
  // @cast-boundary db-row — executor.create returnt Record-row aus
171
221
  // INSERT RETURNING; shape { id } ist garantiert weil PK in der
@@ -3,14 +3,74 @@ import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framew
3
3
  import * as z from "zod";
4
4
  import { TenantQueries } from "../../tenant/index.js";
5
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
6
+ import { servedVersionOf } from "../template-draft.js";
7
+ import { buildOverrideDiff } from "./override-diff.js";
6
8
  import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
7
9
 
8
10
  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";
11
+ "SystemAdmin template overview lists which tenants override one system template; it reads tenant-scope template rows of every tenant (and the system row for the optional per-tenant diff), which the tenant-scoped db cannot reach";
10
12
 
11
13
  const tenantIdsSchema = z.array(z.string());
14
+ const directorySchema = z.object({
15
+ rows: z.array(z.object({ id: z.string(), label: z.string() })),
16
+ });
17
+
18
+ type OverrideStatus = "active" | "draft";
19
+
20
+ function labelsOf(raw: unknown): ReadonlyMap<string, string> {
21
+ return new Map(directorySchema.parse(raw).rows.map((row) => [row.id, row.label]));
22
+ }
23
+
24
+ // One row per tenant: its active override wins over a draft, then the newest version.
25
+ function isBetterRow(candidate: TemplateResourceRow, current: TemplateResourceRow): boolean {
26
+ if ((candidate.status === "active") !== (current.status === "active")) {
27
+ return candidate.status === "active";
28
+ }
29
+ return candidate.version > current.version;
30
+ }
31
+
32
+ function statusOf(row: TemplateResourceRow): OverrideStatus {
33
+ return row.status === "active" ? "active" : "draft";
34
+ }
12
35
 
13
- type OverrideRow = Pick<TemplateResourceRow, "tenantId" | "status">;
36
+ function isCountedTenantRow(
37
+ row: TemplateResourceRow,
38
+ enabledTenantIds: ReadonlySet<string> | null,
39
+ ): boolean {
40
+ if (row.tenantId === SYSTEM_TENANT_ID || row.status === "archived") return false;
41
+ return enabledTenantIds === null || enabledTenantIds.has(row.tenantId);
42
+ }
43
+
44
+ function pickBestRowPerTenant(
45
+ found: readonly TemplateResourceRow[],
46
+ enabledTenantIds: ReadonlySet<string> | null,
47
+ ): Map<string, TemplateResourceRow> {
48
+ const bestByTenant = new Map<string, TemplateResourceRow>();
49
+ for (const row of found) {
50
+ if (!isCountedTenantRow(row, enabledTenantIds)) continue;
51
+ const current = bestByTenant.get(row.tenantId);
52
+ if (current === undefined || isBetterRow(row, current)) bestByTenant.set(row.tenantId, row);
53
+ }
54
+ return bestByTenant;
55
+ }
56
+
57
+ function toOverrideRow(
58
+ row: TemplateResourceRow,
59
+ tenantLabels: ReadonlyMap<string, string>,
60
+ userLabels: ReadonlyMap<string, string>,
61
+ ) {
62
+ const changedById = row.modifiedById ?? row.insertedById;
63
+ return {
64
+ tenantId: row.tenantId,
65
+ tenantName: tenantLabels.get(row.tenantId) ?? row.tenantId,
66
+ templateId: String(row.id),
67
+ locale: row.locale,
68
+ status: statusOf(row),
69
+ version: servedVersionOf(row),
70
+ changedAt: (row.modifiedAt ?? row.insertedAt).toString(),
71
+ changedBy: changedById === null ? null : (userLabels.get(changedById) ?? null),
72
+ };
73
+ }
14
74
 
15
75
  export const tenantOverridesQuery = defineQueryHandler({
16
76
  name: "tenant-overrides",
@@ -18,14 +78,17 @@ export const tenantOverridesQuery = defineQueryHandler({
18
78
  slug: templateSlugSchema,
19
79
  kind: kindSchema,
20
80
  locale: localeSchema.optional(),
81
+ /** Adds the block diff of this tenant's override against the system template. */
82
+ diffTenantId: z.string().min(1).optional(),
21
83
  }),
22
84
  access: { roles: ["SystemAdmin"] },
23
85
  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.",
86
+ "Lists the enabled tenants that override one template with an active or a draft version (tenant name, status, version, last change and who made it), next to the counts and the number of enabled tenants; use it before changing a system template to see who keeps their own copy. With diffTenantId it also returns the block diff of that tenant's override against the system template.",
25
87
  escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
26
88
  handler: async (query, ctx) => {
27
- const { slug, kind, locale } = query.payload;
28
- const rows = await selectMany<OverrideRow>(ctx.db.unsafeRaw(), templateResourcesTable, {
89
+ const { slug, kind, locale, diffTenantId } = query.payload;
90
+ const raw = ctx.db.unsafeRaw();
91
+ const found = await selectMany<TemplateResourceRow>(raw, templateResourcesTable, {
29
92
  slug,
30
93
  kind,
31
94
  scope: "tenant",
@@ -38,18 +101,37 @@ export const tenantOverridesQuery = defineQueryHandler({
38
101
  ? new Set(tenantIdsSchema.parse(await ctx.query(TenantQueries.activeTenantIds, {})))
39
102
  : null;
40
103
 
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");
104
+ const bestByTenant = pickBestRowPerTenant(found, enabledTenantIds);
105
+ const best = [...bestByTenant.values()];
106
+ const tenantLabels = hasTenantFeature
107
+ ? labelsOf(await ctx.query(TenantQueries.tenantDirectory, {}))
108
+ : new Map<string, string>();
109
+ const userLabels = hasTenantFeature
110
+ ? labelsOf(await ctx.query(TenantQueries.memberDirectory, {}))
111
+ : new Map<string, string>();
112
+
113
+ const rows = best
114
+ .map((row) => toOverrideRow(row, tenantLabels, userLabels))
115
+ .sort((a, b) => a.tenantName.localeCompare(b.tenantName));
116
+
117
+ let diff: ReturnType<typeof buildOverrideDiff> | null = null;
118
+ const diffRow = diffTenantId === undefined ? undefined : bestByTenant.get(diffTenantId);
119
+ if (diffRow !== undefined) {
120
+ const [systemRow] = await selectMany<TemplateResourceRow>(raw, templateResourcesTable, {
121
+ tenantId: SYSTEM_TENANT_ID,
122
+ slug,
123
+ kind,
124
+ locale: diffRow.locale,
125
+ });
126
+ if (systemRow !== undefined) diff = buildOverrideDiff(diffRow, systemRow);
47
127
  }
48
- const statuses = [...statusByTenant.values()];
128
+
49
129
  return {
50
- activeOverrides: statuses.filter((status) => status === "active").length,
51
- draftOverrides: statuses.filter((status) => status === "draft").length,
130
+ activeOverrides: best.filter((row) => row.status === "active").length,
131
+ draftOverrides: best.filter((row) => row.status !== "active").length,
52
132
  totalTenants: enabledTenantIds === null ? null : enabledTenantIds.size,
133
+ rows,
134
+ diff,
53
135
  };
54
136
  },
55
137
  });
@@ -1,24 +1,25 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  defineWriteHandler,
4
- type Registry,
4
+ type HandlerContext,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import {
8
- type FieldIssue,
9
8
  NotFoundError,
10
9
  ValidationError,
10
+ type WriteFailure,
11
11
  writeFailure,
12
12
  } from "@cosmicdrift/kumiko-framework/errors";
13
13
  import * as z from "zod";
14
14
  import { requireForTenant } from "../../shared/index.js";
15
15
  import type { TemplateResourceRow } from "../table.js";
16
16
  import { templateResourcesTable } from "../table.js";
17
+ import { activationChanges, parseTemplateDraft, toWorkingRow } from "../template-draft.js";
17
18
  import { executor } from "./shared.js";
18
19
  import { publishGateIssues } from "./template-gates.js";
19
20
 
20
21
  type TemplateStatus = "active" | "archived";
21
- type StatusTarget = "own" | "system";
22
+ export type StatusTarget = "own" | "system";
22
23
 
23
24
  function isRowOfTarget(row: TemplateResourceRow, target: StatusTarget, callerTenantId: string) {
24
25
  return target === "system"
@@ -26,41 +27,69 @@ function isRowOfTarget(row: TemplateResourceRow, target: StatusTarget, callerTen
26
27
  : row.tenantId === callerTenantId && row.scope === "tenant";
27
28
  }
28
29
 
30
+ export function writeAccessFor(target: StatusTarget) {
31
+ return { roles: target === "system" ? ["SystemAdmin"] : ["TenantAdmin", "SystemAdmin"] };
32
+ }
33
+
34
+ // The tenant db also reads system rows; the row's own tenant must match the
35
+ // target or the executor would append to a foreign event stream.
36
+ export async function loadTargetRow(
37
+ ctx: HandlerContext,
38
+ target: StatusTarget,
39
+ user: { readonly tenantId: string },
40
+ id: string,
41
+ ) {
42
+ const scoped = target === "system" ? requireForTenant(ctx, SYSTEM_TENANT_ID) : null;
43
+ const db = scoped?.db ?? ctx.db;
44
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, { id });
45
+ return { db, scoped, row: row && isRowOfTarget(row, target, user.tenantId) ? row : null };
46
+ }
47
+
48
+ type StatusPlan =
49
+ | { readonly ok: true; readonly changes: Record<string, unknown> }
50
+ | { readonly ok: false; readonly failure: WriteFailure };
51
+
52
+ // Publishing promotes an open draft (gated as the version that goes live); a row
53
+ // without one is activated as stored. Archiving leaves the draft standing.
54
+ function planStatusChange(
55
+ ctx: HandlerContext,
56
+ row: TemplateResourceRow,
57
+ status: TemplateStatus,
58
+ ): StatusPlan {
59
+ if (status === "archived") return { ok: true, changes: { status } };
60
+ const issues = publishGateIssues(ctx.registry, toWorkingRow(row, row.tenantId));
61
+ if (issues.length > 0) {
62
+ return { ok: false, failure: writeFailure(new ValidationError({ fields: [...issues] })) };
63
+ }
64
+ return { ok: true, changes: { ...parseTemplateDraft(row.draft), ...activationChanges(row) } };
65
+ }
66
+
29
67
  function createStatusUpdateHandler(
30
68
  name: string,
31
69
  target: StatusTarget,
32
70
  status: TemplateStatus,
33
71
  description: string,
34
- gate?: (registry: Registry, row: TemplateResourceRow) => readonly FieldIssue[],
35
72
  ) {
36
73
  return defineWriteHandler({
37
74
  name,
38
75
  schema: z.object({ id: z.string().min(1) }),
39
- access: { roles: target === "system" ? ["SystemAdmin"] : ["TenantAdmin", "SystemAdmin"] },
76
+ access: writeAccessFor(target),
40
77
  // A system template change reaches every tenant without an override.
41
78
  ...(target === "system" ? { agent: { risk: "high" as const } } : {}),
42
79
  description,
43
80
  handler: async (event, ctx) => {
44
- const scoped = target === "system" ? requireForTenant(ctx, SYSTEM_TENANT_ID) : null;
45
- const db = scoped?.db ?? ctx.db;
46
- // The tenant db also reads system rows; the row's own tenant must match
47
- // the target or the executor would append to a foreign event stream.
48
- const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
49
- id: event.payload.id,
50
- });
51
- if (!existing || !isRowOfTarget(existing, target, event.user.tenantId)) {
52
- return writeFailure(new NotFoundError("template-resource", event.payload.id));
53
- }
54
- const issues = gate?.(ctx.registry, existing) ?? [];
55
- if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
81
+ const { db, scoped, row } = await loadTargetRow(ctx, target, event.user, event.payload.id);
82
+ if (!row) return writeFailure(new NotFoundError("template-resource", event.payload.id));
83
+ const plan = planStatusChange(ctx, row, status);
84
+ if (!plan.ok) return plan.failure;
56
85
  const result = await executor.update(
57
- { id: existing.id, version: existing.version, changes: { status } },
86
+ { id: row.id, version: row.version, changes: plan.changes },
58
87
  event.user,
59
88
  db,
60
89
  scoped ? { streamTenantId: scoped.streamTenantId } : undefined,
61
90
  );
62
91
  if (!result.isSuccess) return result;
63
- return { isSuccess: true as const, data: { id: String(existing.id), status } };
92
+ return { isSuccess: true as const, data: { id: String(row.id), status } };
64
93
  },
65
94
  });
66
95
  }
@@ -69,14 +98,13 @@ export const archiveWrite = createStatusUpdateHandler(
69
98
  "archive",
70
99
  "own",
71
100
  "archived",
72
- "Sets one template resource of the caller's tenant to archived so the resolver stops serving it while the row and its content survive; use it to retire a template reversibly instead of deleting it.",
101
+ "Sets one template resource of the caller's tenant to archived so the resolver stops serving it while the row and its content survive; use it to retire a template reversibly instead of deleting it. An open draft stays.",
73
102
  );
74
103
  export const publishWrite = createStatusUpdateHandler(
75
104
  "publish",
76
105
  "own",
77
106
  "active",
78
- "Sets one template resource of the caller's tenant to active so the resolver starts serving it; use it to release a draft written by upsert-tenant, or to bring an archived template back; a mail template with unknown placeholders or unused required variables is refused.",
79
- publishGateIssues,
107
+ "Sets one template resource of the caller's tenant to active so the resolver starts serving it; use it to release a draft written by upsert-tenant (a draft saved beside an active version replaces it), or to bring an archived template back; a version with unknown placeholders or unused required variables is refused and the served version stays.",
80
108
  );
81
109
  export const archiveSystemWrite = createStatusUpdateHandler(
82
110
  "archive-system",
@@ -88,6 +116,5 @@ export const publishSystemWrite = createStatusUpdateHandler(
88
116
  "publish-system",
89
117
  "system",
90
118
  "active",
91
- "Sets one system-default template to active so the resolver serves it again to every tenant that has no override; SystemAdmin only, a mail template with unknown placeholders or unused required variables is refused.",
92
- publishGateIssues,
119
+ "Sets one system-default template to active so the resolver serves it again to every tenant that has no override; an open draft beside the active version replaces it; SystemAdmin only, a version with unknown placeholders or unused required variables is refused.",
93
120
  );
@@ -17,7 +17,7 @@ export const upsertTenantWrite = defineWriteHandler({
17
17
  }),
18
18
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
19
19
  description:
20
- "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live; it refuses a system-tenant target, which is what upsert-system is for.",
20
+ "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live (over an active override the draft is stored beside it, the active version keeps being served until publish); it refuses a system-tenant target, which is what upsert-system is for.",
21
21
  handler: async (event, ctx) => {
22
22
  // @cast-boundary engine-payload — override aus Zod-parsed string,
23
23
  // event.user.tenantId schon TenantId-branded; union als TenantId casten
@@ -1,17 +1,31 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import * as z from "zod";
3
4
  import { TEMPLATE_KINDS } from "../constants.js";
5
+ import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
4
6
  import { templateSlugSchema } from "../template-meta.js";
5
7
  import { collectTemplateUsage } from "../template-usage.js";
8
+ import { effectiveMailLayoutSlugs } from "./overview.query.js";
9
+
10
+ const MAIL_ROW_LIMIT = 5000;
6
11
 
7
12
  export const usageQuery = defineQueryHandler({
8
13
  name: "usage",
9
14
  schema: z.object({ slug: templateSlugSchema, kind: z.enum(TEMPLATE_KINDS) }),
10
15
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
11
16
  description:
12
- "Lists the code-registered notifications that use one template: a mail template is used by the notification whose qualified name is its slug, a mail layout by every mail notification that names it in its meta or falls back to the default layout; use it to show what a change to a template affects.",
17
+ "Lists the code-registered notifications that use one template: a mail template is used by the notification whose qualified name is its slug and that has a code template, a mail layout by every mail notification whose effective template (own override, else system) names it, or whose code meta does when no stored template exists, or that falls back to the default layout; use it to show what a change to a template affects.",
13
18
  handler: async (query, ctx) => {
14
19
  const { slug, kind } = query.payload;
15
- return { usedBy: collectTemplateUsage(ctx.registry, slug, kind) };
20
+ if (kind !== "mail-layout") return { usedBy: collectTemplateUsage(ctx.registry, slug, kind) };
21
+ const mailRows = await selectMany<TemplateResourceRow>(
22
+ ctx.db,
23
+ templateResourcesTable,
24
+ { kind: "mail" },
25
+ { limit: MAIL_ROW_LIMIT },
26
+ );
27
+ return {
28
+ usedBy: collectTemplateUsage(ctx.registry, slug, kind, effectiveMailLayoutSlugs(mailRows)),
29
+ };
16
30
  },
17
31
  });
@@ -10,6 +10,7 @@ import type { ContentFormat, UpsertKind } from "../constants.js";
10
10
  import type { TemplateWriteScope } from "../layout-regions.js";
11
11
  import { imageResourceKeysOf } from "../linked-resources.js";
12
12
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
13
+ import { isSystemAdmin } from "../template-draft.js";
13
14
  import { resolveTemplateVariables, systemPlaceholdersFor } from "../template-validation.js";
14
15
  import { contentFormatSchema, kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
15
16
  import { evaluateTemplateWrite } from "./template-gates.js";
@@ -53,7 +54,9 @@ export async function validateTemplateDraft(
53
54
  payload: TemplateDraftInput,
54
55
  ) {
55
56
  const variables = resolveTemplateVariables(ctx.registry, payload.slug, payload.variableSchema);
56
- const { slug, kind, scope, locale } = payload;
57
+ const { slug, kind, locale } = payload;
58
+ // The dry run must not promise what the caller's write handlers would refuse.
59
+ const scope: TemplateWriteScope = isSystemAdmin(user) ? payload.scope : "tenant";
57
60
  const known = [
58
61
  ...(await knownResourceKeys(ctx.db, {
59
62
  tenantId: SYSTEM_TENANT_ID,
@@ -16,12 +16,18 @@ export {
16
16
  CONTENT_FORMATS,
17
17
  type ContentFormat,
18
18
  DEFAULT_MAIL_LAYOUT_SLUG,
19
+ DEFAULT_PDF_LAYOUT_SLUG,
19
20
  DOCUMENT_HTML_KINDS,
20
21
  type DocumentHtmlKind,
21
22
  FALLBACK_LOCALE,
22
23
  NON_RENDER_KINDS,
23
24
  type NonRenderKind,
25
+ PDF_DEFAULT_MARGIN_MM,
26
+ PDF_FOOTER_MARGIN_MM,
27
+ PDF_HEADER_MARGIN_MM,
28
+ PDF_ORIENTATIONS,
24
29
  PDF_PAGE_FORMATS,
30
+ type PdfOrientation,
25
31
  type PdfPageFormat,
26
32
  RENDER_KINDS,
27
33
  type RenderKind,
@@ -52,7 +58,6 @@ export {
52
58
  DocumentDivider,
53
59
  DocumentLink,
54
60
  DocumentPageBreak,
55
- DocumentRepeatRow,
56
61
  DocumentResource,
57
62
  DocumentTable,
58
63
  DocumentTableCell,
@@ -77,6 +82,11 @@ export type {
77
82
  OverviewUsage,
78
83
  } from "./handlers/overview.query.js";
79
84
  export { localeSchema } from "./handlers/shared.js";
85
+ export {
86
+ isHexColor,
87
+ type MailLayoutAppearance,
88
+ mailLayoutAppearance,
89
+ } from "./layout-appearance.js";
80
90
  export {
81
91
  type LayoutRegions,
82
92
  readLayoutRegions,
@@ -101,9 +111,11 @@ export {
101
111
  } from "./qualified-names.js";
102
112
  export { mapRecordToVariables } from "./record-mapping.js";
103
113
  export {
114
+ type PreparedTemplateImage,
115
+ prepareTemplateResourceBeforeTransaction,
104
116
  readTemplateResourceMaxBytes,
105
117
  type StoredTemplateResource,
106
- storeTemplateResource,
118
+ storePreparedTemplateResource,
107
119
  } from "./store-template-resource.js";
108
120
  export {
109
121
  type TemplateResourceRow,
@@ -115,11 +127,15 @@ export {
115
127
  isMailTemplateMeta,
116
128
  type LayoutTemplateMeta,
117
129
  MAIL_CATEGORIES,
130
+ MAIL_TONES,
118
131
  type MailCategory,
132
+ type MailLayoutMeta,
119
133
  type MailTemplateMeta,
134
+ type MailTone,
120
135
  type PdfTemplateMeta,
121
136
  parseTemplateMeta,
122
137
  pdfAttachmentFileNamePatternSchema,
138
+ readMailLayoutMeta,
123
139
  readPdfTemplateMeta,
124
140
  type TemplateMeta,
125
141
  type TemplateMetaIssue,
@@ -0,0 +1,56 @@
1
+ import { HEX_COLOR_PATTERN } from "./constants.js";
2
+ import { type MailLayoutMeta, readMailLayoutMeta } from "./template-meta.js";
3
+
4
+ // Every value is a member of a closed set, so it can be written into MJML and inline styles as is.
5
+ export type MailLayoutAppearance = {
6
+ readonly width: number;
7
+ readonly fontFamily: string;
8
+ readonly outerBackground: string;
9
+ readonly accentLine: boolean;
10
+ readonly buttonStyle: string;
11
+ };
12
+
13
+ const FONT_FAMILIES = {
14
+ sans: "Arial, Helvetica, sans-serif",
15
+ serif: "Georgia, 'Times New Roman', serif",
16
+ } as const;
17
+
18
+ const DEFAULT_BUTTON_COLOR = "#1f5468";
19
+ const BUTTON_BASE = "display:inline-block;padding:10px 20px;font-weight:600;text-decoration:none";
20
+
21
+ type ButtonStyle = NonNullable<MailLayoutMeta["buttonStyle"]>;
22
+
23
+ function buttonStyleCss(style: ButtonStyle, color: string): string {
24
+ switch (style) {
25
+ case "filled-rounded":
26
+ return `${BUTTON_BASE};border-radius:6px;background-color:${color};color:#ffffff`;
27
+ case "filled-square":
28
+ return `${BUTTON_BASE};background-color:${color};color:#ffffff`;
29
+ case "outline":
30
+ return `${BUTTON_BASE};border:2px solid ${color};border-radius:6px;color:${color}`;
31
+ }
32
+ }
33
+
34
+ export function isHexColor(value: string): boolean {
35
+ return HEX_COLOR_PATTERN.test(value);
36
+ }
37
+
38
+ // The branding accent colors the buttons only where the layout opted into the branding accent line.
39
+ export function mailLayoutAppearance(
40
+ meta: MailLayoutMeta | unknown,
41
+ brandingAccentColor?: string,
42
+ ): MailLayoutAppearance {
43
+ const parsed = readMailLayoutMeta(meta);
44
+ const accentLine = (parsed.accentLine ?? "none") === "branding";
45
+ const buttonColor =
46
+ accentLine && brandingAccentColor !== undefined && isHexColor(brandingAccentColor)
47
+ ? brandingAccentColor
48
+ : DEFAULT_BUTTON_COLOR;
49
+ return {
50
+ width: parsed.width ?? 600,
51
+ fontFamily: FONT_FAMILIES[parsed.font ?? "sans"],
52
+ outerBackground: parsed.outerBackground ?? "#f5f5f5",
53
+ accentLine,
54
+ buttonStyle: buttonStyleCss(parsed.buttonStyle ?? "filled-rounded", buttonColor),
55
+ };
56
+ }
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+ // No imports: web/ re-exports these, and template-meta.ts pulls in zod and the framework engine.
3
+ export const MAIL_TONES = ["accent", "info", "success", "warning", "danger", "neutral"] as const;
4
+ export type MailTone = (typeof MAIL_TONES)[number];
@@ -0,0 +1,12 @@
1
+ // @runtime client
2
+ // No imports: web/ re-exports these, and constants.ts pulls in the framework engine.
3
+ export const PDF_PAGE_FORMATS = ["A4", "Letter"] as const;
4
+ export type PdfPageFormat = (typeof PDF_PAGE_FORMATS)[number];
5
+
6
+ export const PDF_ORIENTATIONS = ["portrait", "landscape"] as const;
7
+ export type PdfOrientation = (typeof PDF_ORIENTATIONS)[number];
8
+
9
+ // Page margins in mm; the header/footer values apply when the layout contributes that region and the template sets no margin itself.
10
+ export const PDF_DEFAULT_MARGIN_MM = 15;
11
+ export const PDF_HEADER_MARGIN_MM = 30;
12
+ export const PDF_FOOTER_MARGIN_MM = 20;
@@ -9,6 +9,8 @@ export const TemplateResolverHandlers = {
9
9
  archive: "template-resolver:write:archive",
10
10
  publishSystem: "template-resolver:write:publish-system",
11
11
  archiveSystem: "template-resolver:write:archive-system",
12
+ discardDraft: "template-resolver:write:discard-draft",
13
+ discardDraftSystem: "template-resolver:write:discard-draft-system",
12
14
  resetToSystem: "template-resolver:write:reset-to-system",
13
15
  set: "template-resolver:write:set",
14
16
  importBundle: "template-resolver:write:import-bundle",
@@ -53,10 +55,12 @@ export const TemplateResolverErrors = {
53
55
  invalidSlug: "invalid_slug",
54
56
  invalidLocale: "invalid_locale",
55
57
  systemAdminRequired: "system_admin_required",
58
+ noDraft: "no_draft",
56
59
  alreadyActive: "template_already_active",
57
60
  alreadyArchived: "template_already_archived",
58
61
  targetExists: "target_exists",
59
62
  systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
63
+ bundleLocalesDiffer: "bundle_locales_differ",
60
64
  resourceTooLarge: "template_resource_too_large",
61
65
  resourceUnsupportedImage: "template_resource_unsupported_image",
62
66
  attachmentUnsupportedType: "template_attachment_unsupported_type",