@cosmicdrift/kumiko-samples 0.365.1 → 0.367.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 (208) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/admin-shell/feature.ts +9 -9
  4. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +87 -5
  6. package/packages/bundled-features/src/audit/feature.ts +1 -1
  7. package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +0 -2
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -3
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-mfa/feature.ts +4 -4
  12. package/packages/bundled-features/src/auth-mfa-user-data/index.ts +1 -1
  13. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +1 -1
  14. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  15. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +89 -28
  16. package/packages/bundled-features/src/cap-counter/changes.json +7 -0
  17. package/packages/bundled-features/src/cap-counter/constants.ts +3 -0
  18. package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
  19. package/packages/bundled-features/src/cap-counter/tables.ts +5 -2
  20. package/packages/bundled-features/src/cap-overview/feature.ts +3 -3
  21. package/packages/bundled-features/src/channel-discord/feature.ts +2 -2
  22. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  23. package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
  24. package/packages/bundled-features/src/channel-email/feature.ts +2 -2
  25. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
  26. package/packages/bundled-features/src/channel-push/feature.ts +1 -1
  27. package/packages/bundled-features/src/channel-slack/feature.ts +2 -2
  28. package/packages/bundled-features/src/channel-teams/feature.ts +2 -2
  29. package/packages/bundled-features/src/channel-telegram/feature.ts +2 -2
  30. package/packages/bundled-features/src/compliance-profiles/feature.ts +1 -1
  31. package/packages/bundled-features/src/compliance-profiles-ops/index.ts +3 -3
  32. package/packages/bundled-features/src/config/feature.ts +1 -1
  33. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  34. package/packages/bundled-features/src/custom-fields/feature.ts +2 -2
  35. package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +17 -13
  36. package/packages/bundled-features/src/data-retention/feature.ts +1 -1
  37. package/packages/bundled-features/src/data-retention/presets.ts +4 -4
  38. package/packages/bundled-features/src/delivery/changes.json +18 -0
  39. package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
  40. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +6 -2
  41. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +4 -4
  42. package/packages/bundled-features/src/delivery/delivery-service.ts +71 -4
  43. package/packages/bundled-features/src/delivery/feature.ts +10 -6
  44. package/packages/bundled-features/src/delivery/handlers/resubscribe-address.write.ts +12 -8
  45. package/packages/bundled-features/src/delivery/handlers/resubscribe-user.write.ts +3 -1
  46. package/packages/bundled-features/src/delivery/handlers/set-preference.write.ts +3 -1
  47. package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +13 -6
  48. package/packages/bundled-features/src/delivery/handlers/unsubscribe-user.write.ts +3 -1
  49. package/packages/bundled-features/src/delivery/index.ts +6 -0
  50. package/packages/bundled-features/src/delivery/notification-type-aliases.ts +13 -0
  51. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  52. package/packages/bundled-features/src/delivery/types.ts +4 -0
  53. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
  54. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
  55. package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
  56. package/packages/bundled-features/src/derivatives-sharp/feature.ts +1 -1
  57. package/packages/bundled-features/src/document-ingest-foundation/feature.ts +2 -2
  58. package/packages/bundled-features/src/file-derivatives/feature.ts +3 -3
  59. package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
  60. package/packages/bundled-features/src/file-provider-inmemory/feature.ts +2 -2
  61. package/packages/bundled-features/src/file-provider-s3/feature.ts +3 -3
  62. package/packages/bundled-features/src/file-provider-s3-env/feature.ts +2 -2
  63. package/packages/bundled-features/src/files/README.md +1 -1
  64. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  65. package/packages/bundled-features/src/folders/feature.ts +2 -2
  66. package/packages/bundled-features/src/folders-user-data/index.ts +1 -1
  67. package/packages/bundled-features/src/form-draft/feature.ts +2 -2
  68. package/packages/bundled-features/src/form-draft-user-data/index.ts +1 -1
  69. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +9 -1
  71. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  72. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +19 -7
  73. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +21 -2
  74. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -2
  75. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +1 -1
  76. package/packages/bundled-features/src/ledger/feature.ts +5 -5
  77. package/packages/bundled-features/src/legal-pages/feature.ts +1 -1
  78. package/packages/bundled-features/src/mail-foundation/feature.ts +51 -10
  79. package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
  80. package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +2 -2
  81. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +3 -3
  82. package/packages/bundled-features/src/managed-pages/css-gate.ts +1 -1
  83. package/packages/bundled-features/src/managed-pages/feature.ts +2 -2
  84. package/packages/bundled-features/src/metrics/feature.ts +2 -2
  85. package/packages/bundled-features/src/notes-history/feature.ts +2 -2
  86. package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
  87. package/packages/bundled-features/src/personal-access-tokens/feature.ts +15 -11
  88. package/packages/bundled-features/src/readiness/feature.ts +2 -2
  89. package/packages/bundled-features/src/renderer-foundation/README.md +1 -1
  90. package/packages/bundled-features/src/renderer-foundation/feature.ts +1 -1
  91. package/packages/bundled-features/src/renderer-pdf/feature.ts +1 -1
  92. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  93. package/packages/bundled-features/src/seo/feature.ts +1 -1
  94. package/packages/bundled-features/src/sessions/feature.ts +17 -13
  95. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -1
  96. package/packages/bundled-features/src/subscription-mollie/feature.ts +1 -1
  97. package/packages/bundled-features/src/subscription-stripe/feature.ts +3 -3
  98. package/packages/bundled-features/src/tags/feature.ts +2 -2
  99. package/packages/bundled-features/src/template-resolver/changes.json +25 -0
  100. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
  101. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
  102. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
  103. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  104. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
  105. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
  106. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
  107. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
  108. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
  109. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
  110. package/packages/bundled-features/src/template-resolver/index.ts +4 -0
  111. package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
  112. package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
  113. package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
  114. package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
  115. package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
  116. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
  117. package/packages/bundled-features/src/template-resolver-user-data/index.ts +1 -1
  118. package/packages/bundled-features/src/tenant/feature.ts +4 -4
  119. package/packages/bundled-features/src/tenant/seeding.ts +1 -1
  120. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +2 -3
  121. package/packages/bundled-features/src/tenant-settings/feature.ts +1 -1
  122. package/packages/bundled-features/src/tier-engine/feature.ts +36 -27
  123. package/packages/bundled-features/src/user/feature.ts +1 -1
  124. package/packages/bundled-features/src/user-data-rights/README.md +1 -1
  125. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  126. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -4
  127. package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +2 -2
  128. package/packages/bundled-features/src/user-profile/feature.ts +4 -4
  129. package/packages/bundled-features/src/waitlist/feature.ts +2 -2
  130. package/samples/apps/cap-billing-demo/README.md +1 -1
  131. package/samples/apps/cap-billing-demo/src/feature.ts +4 -4
  132. package/samples/apps/cap-billing-demo/src/run-config.ts +1 -1
  133. package/samples/apps/config-demo/src/features/demo/feature.ts +2 -2
  134. package/samples/apps/styleguide/src/features/ai-settings/feature.ts +43 -28
  135. package/samples/apps/styleguide/src/features/demo/feature.ts +18 -18
  136. package/samples/apps/styleguide/src/features/widgets/ai-text-demo.ts +10 -12
  137. package/samples/apps/styleguide/src/features/widgets/feature.ts +90 -108
  138. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  139. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
  140. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
  141. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
  142. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +36 -36
  143. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +6 -6
  144. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +2 -2
  145. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  146. package/samples/apps/use-all-bundled/package.json +1 -0
  147. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +7 -7
  148. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +3 -3
  149. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +1 -1
  150. package/samples/apps/user-data-rights-demo/src/feature.ts +5 -5
  151. package/samples/recipes/access-control/src/feature.ts +21 -25
  152. package/samples/recipes/anonymous-access/src/feature.ts +36 -43
  153. package/samples/recipes/anonymous-access-multitenant/src/feature.ts +14 -16
  154. package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
  155. package/samples/recipes/basic-entity/README.md +1 -1
  156. package/samples/recipes/boot-check/README.md +1 -1
  157. package/samples/recipes/boot-check/src/feature.ts +2 -2
  158. package/samples/recipes/chat-channels/src/feature.ts +1 -1
  159. package/samples/recipes/cross-feature-events/src/feature.ts +7 -7
  160. package/samples/recipes/currencies-global/src/feature.ts +3 -3
  161. package/samples/recipes/currencies-per-tenant/src/feature.ts +2 -2
  162. package/samples/recipes/custom-fields-basic/README.md +1 -1
  163. package/samples/recipes/custom-fields-basic/src/feature.ts +3 -2
  164. package/samples/recipes/custom-handlers/README.md +3 -3
  165. package/samples/recipes/custom-handlers/src/feature.ts +37 -41
  166. package/samples/recipes/dashboard-stats/src/feature.ts +6 -6
  167. package/samples/recipes/delivery-notifications/README.md +1 -1
  168. package/samples/recipes/delivery-notifications/src/feature.ts +8 -8
  169. package/samples/recipes/designer-demo/src/feature.ts +2 -1
  170. package/samples/recipes/embedded/src/feature.ts +1 -1
  171. package/samples/recipes/embedded-entity-form/src/feature.ts +13 -13
  172. package/samples/recipes/encrypted-tenant-config/src/feature.ts +1 -1
  173. package/samples/recipes/error-contract/src/feature.ts +25 -25
  174. package/samples/recipes/event-sourcing/src/feature.ts +70 -76
  175. package/samples/recipes/feature-toggles/src/feature.ts +28 -20
  176. package/samples/recipes/files-post-processing/src/feature.ts +22 -26
  177. package/samples/recipes/idempotency/src/feature.ts +16 -18
  178. package/samples/recipes/inbound-mail-triage/src/feature.ts +1 -1
  179. package/samples/recipes/lane-routing/src/feature.ts +10 -12
  180. package/samples/recipes/legal-pages/src/feature.ts +1 -1
  181. package/samples/recipes/lifecycle-hooks/README.md +4 -4
  182. package/samples/recipes/lifecycle-hooks/src/feature.ts +55 -35
  183. package/samples/recipes/managed-config/src/feature.ts +8 -8
  184. package/samples/recipes/managed-pages/src/feature.ts +1 -1
  185. package/samples/recipes/notes-history-basic/README.md +1 -1
  186. package/samples/recipes/notes-history-basic/src/feature.ts +2 -2
  187. package/samples/recipes/pdf-audit-export/src/feature.ts +7 -7
  188. package/samples/recipes/pipeline-basics/README.md +1 -1
  189. package/samples/recipes/pipeline-basics/src/feature.ts +7 -7
  190. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  191. package/samples/recipes/rate-limiting/src/feature.ts +7 -9
  192. package/samples/recipes/record-detail-layout/src/feature.ts +18 -18
  193. package/samples/recipes/reference-data/src/feature.ts +1 -1
  194. package/samples/recipes/relations/src/feature.ts +2 -2
  195. package/samples/recipes/screens-nav/src/feature.ts +1 -1
  196. package/samples/recipes/search/src/feature.ts +17 -19
  197. package/samples/recipes/security-baseline/README.md +1 -1
  198. package/samples/recipes/state-machine/src/feature.ts +1 -1
  199. package/samples/recipes/tags-basic/README.md +1 -1
  200. package/samples/recipes/tags-basic/src/feature.ts +2 -2
  201. package/samples/recipes/tenant-settings/README.md +1 -1
  202. package/samples/recipes/tenant-settings/src/feature.ts +1 -1
  203. package/samples/recipes/timezones/src/feature.ts +12 -12
  204. package/samples/recipes/user-data-rights/README.md +2 -2
  205. package/samples/recipes/user-data-rights/src/feature.ts +1 -1
  206. package/samples/recipes/user-profile/src/feature.ts +1 -1
  207. package/samples/recipes/webhook-step/src/feature.ts +1 -1
  208. package/samples/recipes/writeform-section/src/feature.ts +12 -12
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "improvement",
5
+ "title": "Notifications can keep their mail template system-only",
6
+ "detail": "r.notification(name, { systemOnly: true }) marks the stored mail template as a platform text. Tenant admins no longer see it in the template overview, list or detail, and creating a tenant version (upsert-tenant, copy-locale, import-bundle, also a SystemAdmin writing for a tenant) is refused with access_denied and templateResolver.errors.systemOnlyTemplate. SystemAdmins edit the system template as before, and the mail renderer always uses it, even if a tenant row exists from before."
7
+ },
8
+ {
9
+ "version": "0.367.0",
10
+ "type": "fix",
11
+ "title": "Rich text placeholders no longer nest paragraphs",
12
+ "detail": "A richText placeholder alone in a paragraph compiles into a div that keeps the paragraph attributes, and one inside other text renders inline with line breaks instead of its own paragraphs. Mails and PDFs no longer contain <p><p>. richText and stepIs are now reserved template variable names, like the other renderer helpers.",
13
+ "migration": "A notification that declares a template variable called richText, stepIs or percent fails at boot; rename the variable."
14
+ },
15
+ {
16
+ "version": "0.367.0",
17
+ "type": "improvement",
18
+ "title": "Template bundles with a logo can be seeded",
19
+ "detail": "seedSystemTemplateBundle(db, bundle, { dispatcher }) now seeds bundles that carry resources such as a logo by running the import-bundle-system write on the given seed dispatcher. With ifExists \"skip\" (default) nothing is written when every locale row already exists; if only some exist, the whole bundle is imported again. Without a dispatcher such a bundle still fails with a message that names the missing option."
20
+ },
21
+ {
22
+ "version": "0.367.0",
23
+ "type": "improvement",
24
+ "title": "Template conditions with data-if and a percent variable type",
25
+ "detail": "A paragraph, heading or table row with data-if=\"name\" renders only when the variable has a value (0 counts as a value); inside a repeat row the name refers to the row field, on the repeat row itself to the root variable. The sanitizer keeps the attribute, and template validation reports a condition on an undeclared variable. New variable type percent (templateVariable.percent()) takes percentage points and renders locale-aware, 12 as \"12 %\" in German and \"12%\" in English; the preview uses its example value. The document designer has no toolbar control for conditions yet; the attribute survives editing."
26
+ },
2
27
  {
3
28
  "version": "0.365.0",
4
29
  "type": "breaking",
@@ -3,8 +3,9 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import type { Element as ScanElement } from "happy-dom";
4
4
  import { isResourceImageContentType } from "../shared/index.js";
5
5
  import { rewriteStepsBlock } from "./compile-steps-block.js";
6
- import { withParsedDocument } from "./document-sanitizer.js";
6
+ import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
7
7
  import {
8
+ DOCUMENT_CONDITION_ATTRIBUTE,
8
9
  DOCUMENT_TEXT_ALIGNMENTS,
9
10
  isAllowedDocumentLinkHref,
10
11
  isDocumentPlaceholderHref,
@@ -25,6 +26,8 @@ type RowScope = { readonly listName: string; readonly itemFields: TemplateVariab
25
26
  type CompileState = {
26
27
  readonly variables: TemplateVariables;
27
28
  readonly expressions: string[];
29
+ // richText spans that fill a whole block; their value brings its own paragraphs.
30
+ readonly blockRichTextSpans: Set<ScanElement>;
28
31
  };
29
32
 
30
33
  function marker(state: CompileState, expression: string): string {
@@ -59,10 +62,19 @@ function resolveReference(
59
62
  return { path: scope === null ? name : `@root.${name}`, def: ownVariable(variables, name) };
60
63
  }
61
64
 
62
- function valueExpression(path: string, def: TemplateVariableDef | undefined): string {
65
+ // The sanitized rich text value always consists of blocks; inside a line of
66
+ // other text it renders inline so no paragraph ends up inside a paragraph.
67
+ function valueExpression(
68
+ path: string,
69
+ def: TemplateVariableDef | undefined,
70
+ isWholeBlock: boolean,
71
+ ): string {
63
72
  if (def?.type === "money") return `{{money ${path}}}`;
64
73
  if (def?.type === "date") return `{{date ${path}}}`;
65
- if (def?.type === "richText") return `{{richText ${path}}}`;
74
+ if (def?.type === "percent") return `{{percent ${path}}}`;
75
+ if (def?.type === "richText") {
76
+ return isWholeBlock ? `{{richText ${path}}}` : `{{richText ${path} inline=true}}`;
77
+ }
66
78
  return `{{${path}}}`;
67
79
  }
68
80
 
@@ -70,7 +82,13 @@ function rewriteVariable(element: ScanElement, state: CompileState): void {
70
82
  const name = element.getAttribute("data-variable") ?? "";
71
83
  const replacement = element.ownerDocument.createTextNode(
72
84
  isDocumentReferenceName(name)
73
- ? marker(state, valueExpression(...pathAndDef(name, element, state)))
85
+ ? marker(
86
+ state,
87
+ valueExpression(
88
+ ...pathAndDef(name, element, state),
89
+ state.blockRichTextSpans.has(element),
90
+ ),
91
+ )
74
92
  : "",
75
93
  );
76
94
  element.replaceWith(replacement);
@@ -112,10 +130,27 @@ function wrapVariableOnlyBlock(block: ScanElement, state: CompileState): void {
112
130
  const name = soleVariableName(block);
113
131
  // skip: a line with static content or several placeholders stays as it is
114
132
  if (name === null) return;
115
- const [path] = pathAndDef(name, block, state);
116
- const document = block.ownerDocument;
117
- block.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
118
- block.after(document.createTextNode(marker(state, "{{/if}}")));
133
+ const [path, def] = pathAndDef(name, block, state);
134
+ const host =
135
+ def?.type === "richText" && block.tagName === "P" ? richTextBlockHost(block, state) : block;
136
+ const document = host.ownerDocument;
137
+ host.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
138
+ host.after(document.createTextNode(marker(state, "{{/if}}")));
139
+ }
140
+
141
+ // A paragraph cannot hold the paragraphs of a rich text value, a div can; it
142
+ // keeps the paragraph's attributes so alignment and size still apply.
143
+ function richTextBlockHost(paragraph: ScanElement, state: CompileState): ScanElement {
144
+ const host = paragraph.ownerDocument.createElement("div");
145
+ for (const attribute of Array.from(paragraph.attributes)) {
146
+ host.setAttribute(attribute.name, attribute.value ?? "");
147
+ }
148
+ for (const span of Array.from(paragraph.querySelectorAll("span[data-variable]"))) {
149
+ state.blockRichTextSpans.add(span);
150
+ }
151
+ host.append(...Array.from(paragraph.childNodes));
152
+ paragraph.replaceWith(host);
153
+ return host;
119
154
  }
120
155
 
121
156
  function rewriteLink(element: ScanElement, state: CompileState): void {
@@ -146,6 +181,20 @@ function rewriteRepeatRow(row: ScanElement, state: CompileState): void {
146
181
  }
147
182
  }
148
183
 
184
+ function rewriteConditionalBlock(block: ScanElement, state: CompileState): void {
185
+ const name = block.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "";
186
+ block.removeAttribute(DOCUMENT_CONDITION_ATTRIBUTE);
187
+ if (!isDocumentReferenceName(name)) {
188
+ block.remove();
189
+ // skip: a condition on a name that cannot be a variable never shows
190
+ return;
191
+ }
192
+ const [path] = pathAndDef(name, conditionScopeElement(block), state);
193
+ const document = block.ownerDocument;
194
+ block.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
195
+ block.after(document.createTextNode(marker(state, "{{/if}}")));
196
+ }
197
+
149
198
  function stripActiveContent(element: ScanElement): void {
150
199
  for (const attribute of Array.from(element.attributes)) {
151
200
  if (/^on/i.test(attribute.name)) element.removeAttribute(attribute.name);
@@ -311,7 +360,7 @@ export function compileDocumentBodyToHandlebars(
311
360
  variables: TemplateVariables,
312
361
  options: CompileDocumentBodyOptions = {},
313
362
  ): string {
314
- const state: CompileState = { variables, expressions: [] };
363
+ const state: CompileState = { variables, expressions: [], blockRichTextSpans: new Set() };
315
364
  const scrubbed = html.replaceAll(EXPRESSION_OPEN, "").replaceAll(EXPRESSION_CLOSE, "");
316
365
  const compiled = withParsedDocument(scrubbed, (root) => {
317
366
  stripScriptsAndActiveContent(root, options);
@@ -325,6 +374,9 @@ export function compileDocumentBodyToHandlebars(
325
374
  color: options.stepsColor,
326
375
  });
327
376
  }
377
+ for (const block of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
378
+ rewriteConditionalBlock(block, state);
379
+ }
328
380
  for (const block of Array.from(root.querySelectorAll(VARIABLE_ONLY_BLOCK_SELECTOR))) {
329
381
  wrapVariableOnlyBlock(block, state);
330
382
  }
@@ -7,6 +7,7 @@ import { type Element as ScanElement, Window } from "happy-dom";
7
7
  import type { ContentFormat } from "./constants.js";
8
8
  import { DOCUMENT_HTML_KINDS } from "./constants.js";
9
9
  import {
10
+ DOCUMENT_CONDITION_ATTRIBUTE,
10
11
  documentExtensions,
11
12
  isAllowedDocumentLinkHref,
12
13
  isDocumentPlaceholderHref,
@@ -138,6 +139,7 @@ function scanImage(element: ScanElement, out: DocumentViolation[]): void {
138
139
  const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => boolean]> = [
139
140
  ["data-variable", isDocumentReferenceName],
140
141
  ["data-each", isDocumentReferenceName],
142
+ [DOCUMENT_CONDITION_ATTRIBUTE, isDocumentReferenceName],
141
143
  ["data-resource", isDocumentResourceKey],
142
144
  ["data-steps", isDocumentReferenceName],
143
145
  ["data-step", isDocumentStepKey],
@@ -270,6 +272,17 @@ export function sanitizeDocumentHtml(html: string): SanitizeHtmlResult {
270
272
  };
271
273
  }
272
274
 
275
+ // For a sanitized value placed inside a line of text: paragraphs become line
276
+ // breaks. Other blocks (lists, headings) cannot be made inline and stay as they are.
277
+ export function flattenParagraphsToLineBreaks(sanitizedHtml: string): string {
278
+ return withParsedDocument(sanitizedHtml, (body) => {
279
+ const blocks = Array.from(body.children);
280
+ const isParagraphsOnly =
281
+ blocks.length === body.childNodes.length && blocks.every((block) => block.tagName === "P");
282
+ return isParagraphsOnly ? blocks.map((block) => block.innerHTML).join("<br>") : sanitizedHtml;
283
+ });
284
+ }
285
+
273
286
  // Snippets accept any format, but their html is as untrusted as a mail body.
274
287
  function isSanitizedHtmlKind(kind: string): boolean {
275
288
  return kind === "snippet" || isOneOf(DOCUMENT_HTML_KINDS, kind);
@@ -336,3 +349,9 @@ export function documentViolationsToFieldIssues(
336
349
  params: { detail: item.detail },
337
350
  }));
338
351
  }
352
+
353
+ // A repeat row's own data-if is decided per list, outside the row's item scope,
354
+ // because the compiled {{#if}} lands outside its {{#each}}.
355
+ export function conditionScopeElement(block: ScanElement): ScanElement {
356
+ return block.matches("tr[data-each]") ? (block.parentElement ?? block) : block;
357
+ }
@@ -314,6 +314,38 @@ export const DocumentTextSize = Extension.create({
314
314
  },
315
315
  });
316
316
 
317
+ // A block shown only when the named variable has a value (data-if, the
318
+ // conditional counterpart of data-each). Table rows qualify so an invoice can
319
+ // drop a discount line; tiptap merges the attribute into paragraph, heading
320
+ // and row markup only.
321
+ export const DOCUMENT_CONDITION_ATTRIBUTE = "data-if";
322
+
323
+ export const DocumentCondition = Extension.create({
324
+ name: "documentCondition",
325
+ addGlobalAttributes() {
326
+ return [
327
+ {
328
+ types: ["paragraph", "heading", "tableRow"],
329
+ attributes: {
330
+ showIf: {
331
+ default: null,
332
+ parseHTML: (element) => {
333
+ const name = element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE);
334
+ return name !== null && isDocumentReferenceName(name) ? name : null;
335
+ },
336
+ renderHTML: (attributes) => {
337
+ const name: unknown = attributes["showIf"];
338
+ return typeof name === "string" && isDocumentReferenceName(name)
339
+ ? { [DOCUMENT_CONDITION_ATTRIBUTE]: name }
340
+ : {};
341
+ },
342
+ },
343
+ },
344
+ },
345
+ ];
346
+ },
347
+ });
348
+
317
349
  export const documentExtensions: Extensions = [
318
350
  StarterKit.configure({
319
351
  blockquote: false,
@@ -348,4 +380,5 @@ export const documentExtensions: Extensions = [
348
380
  DocumentSteps,
349
381
  DocumentTextAlign,
350
382
  DocumentTextSize,
383
+ DocumentCondition,
351
384
  ];
@@ -99,12 +99,12 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
99
99
  resourceMaxBytes: createSystemConfig("number", { default: 2_097_152, bounds: { min: 1 } }),
100
100
  },
101
101
  });
102
- r.entity("template-resource", templateResourceEntity);
102
+ r.entity({ name: "template-resource", ...templateResourceEntity });
103
103
  // Only when the app actually mounts a user-owned collection: the entity's
104
104
  // `content` is `userOwned`, which makes it subject data — the boot guard
105
105
  // then requires an EXT_USER_DATA hook (mount `template-resolver-user-data`).
106
106
  // Registering it unconditionally would impose that on every app.
107
- if (hasUserOwned) r.entity("user-content-entry", userContentEntryEntity);
107
+ if (hasUserOwned) r.entity({ name: "user-content-entry", ...userContentEntryEntity });
108
108
 
109
109
  const handlers = {
110
110
  upsertSystem: r.writeHandler(upsertSystemWrite),
@@ -32,6 +32,7 @@ import { resolveTemplateVariables } from "../template-validation.js";
32
32
  import { MAX_FILE_ATTACHMENTS_PER_TEMPLATE } from "./resource-shared.js";
33
33
  import { evaluateTemplateWrite } from "./template-gates.js";
34
34
  import {
35
+ refuseSystemOnlyTenantOverride,
35
36
  refuseTenantScopeUnderSystemTenant,
36
37
  type TemplateRowPayload,
37
38
  writeTemplateRow,
@@ -113,7 +114,14 @@ function checkBundleAttachments(bundle: TemplateBundle): WriteResult<null> {
113
114
  export async function prepareTemplateBundleBeforeTransaction(
114
115
  args: BundleImport & { readonly status: BundleImportStatus },
115
116
  ): Promise<BeforeTransactionResult<PreparedBundleResources>> {
116
- const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
117
+ const refused =
118
+ refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId) ??
119
+ refuseSystemOnlyTenantOverride(
120
+ args.ctx.registry,
121
+ args.scope,
122
+ args.bundle.slug,
123
+ args.bundle.kind,
124
+ );
117
125
  if (refused !== null) return refused;
118
126
  const issues = await collectBundleIssues(args, args.status);
119
127
  if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
@@ -30,7 +30,11 @@ import { resolveTemplateVariables } from "../template-validation.js";
30
30
  import { targetExistsIssue } from "./bundle-import.js";
31
31
  import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
32
32
  import { parseStoredJson, storedRecord } from "./template-gates.js";
33
- import { refuseTenantScopeUnderSystemTenant, writeTemplateRow } from "./template-write.js";
33
+ import {
34
+ refuseSystemOnlyTenantOverride,
35
+ refuseTenantScopeUnderSystemTenant,
36
+ writeTemplateRow,
37
+ } from "./template-write.js";
34
38
 
35
39
  type CopyScope = "tenant" | "system";
36
40
  type WriteCtx = HandlerContext;
@@ -108,7 +112,9 @@ async function checkCopyRequest(
108
112
  );
109
113
  }
110
114
  const tenantId = scope === "system" ? SYSTEM_TENANT_ID : event.user.tenantId;
111
- const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
115
+ const refused =
116
+ refuseTenantScopeUnderSystemTenant(scope, tenantId) ??
117
+ refuseSystemOnlyTenantOverride(ctx.registry, scope, slug, kind);
112
118
  if (refused !== null) return refused;
113
119
 
114
120
  const readDb = readDbFor(ctx, scope);
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
5
5
  import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.js";
6
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
6
7
 
7
8
  // Admin-Lookup für UI-Edit-Flow. Returnt das raw Template inkl. draft/
8
9
  // archived Status. Tenant-Isolation: User sieht nur Templates des
@@ -19,7 +20,13 @@ export const findByIdQuery = defineQueryHandler({
19
20
  const stored = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
20
21
  id: query.payload.id,
21
22
  });
22
- if (!stored || isHiddenSystemDraft(query.user, stored)) return null;
23
+ if (
24
+ !stored ||
25
+ isHiddenSystemDraft(query.user, stored) ||
26
+ isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, stored)
27
+ ) {
28
+ return null;
29
+ }
23
30
  const row = toWorkingRow(stored, draftViewerTenantId(query.user, stored));
24
31
  const isOwnTenant = row.tenantId === query.user.tenantId;
25
32
  const isSystemTemplate = row.scope === "system";
@@ -4,6 +4,7 @@ import * as z from "zod";
4
4
  import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../constants.js";
5
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
6
6
  import { isHiddenSystemDraft } from "../template-draft.js";
7
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
7
8
 
8
9
  // ctx.db is tenant-scoped even for SystemAdmin: every caller sees its own
9
10
  // tenant's rows plus the system defaults, never another tenant's rows.
@@ -45,7 +46,11 @@ export const listQuery = defineQueryHandler({
45
46
  })) as TemplateResourceRow[];
46
47
 
47
48
  return rows
48
- .filter((row) => !isHiddenSystemDraft(query.user, row))
49
+ .filter(
50
+ (row) =>
51
+ !isHiddenSystemDraft(query.user, row) &&
52
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row),
53
+ )
49
54
  .map((row) => ({
50
55
  id: String(row.id),
51
56
  tenantId: row.tenantId,
@@ -11,6 +11,7 @@ import {
11
11
  parseTemplateDraft,
12
12
  servedVersionOf,
13
13
  } from "../template-draft.js";
14
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
14
15
  import { collectTemplateUsage, type EffectiveMailLayoutSlugs } from "../template-usage.js";
15
16
  import { storedRecord } from "./template-gates.js";
16
17
 
@@ -284,7 +285,7 @@ export const overviewQuery = defineQueryHandler({
284
285
  }),
285
286
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
286
287
  description:
287
- "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; use it to browse and filter all templates at a glance.",
288
+ "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant (optionally only the mails and PDFs using one layout slug): where the effective version comes from (own override or system), its locales, status and version (the served one; `draftVersion` is set when an unpublished draft waits beside it, `activeSince` says since when the served version applies), the system version it overrides, the layout it uses and which notifications or templates use it; templates of notifications registered with `systemOnly` are listed for SystemAdmins only; use it to browse and filter all templates at a glance.",
288
289
  handler: async (query, ctx) => {
289
290
  const payload = query.payload;
290
291
  const fetchedRows = await selectMany<TemplateResourceRow>(
@@ -301,7 +302,11 @@ export const overviewQuery = defineQueryHandler({
301
302
  });
302
303
  }
303
304
 
304
- const visibleRows = rows.filter((row) => !isHiddenSystemDraft(query.user, row));
305
+ const visibleRows = rows.filter(
306
+ (row) =>
307
+ !isHiddenSystemDraft(query.user, row) &&
308
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row),
309
+ );
305
310
  const { drafts, layoutSlugByDocument } = buildDrafts(
306
311
  groupBySlugAndKind(visibleRows),
307
312
  query.user,
@@ -1,6 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  type HandlerContext,
4
+ type Registry,
4
5
  type SessionUser,
5
6
  SYSTEM_TENANT_ID,
6
7
  type TemplateVariables,
@@ -16,8 +17,10 @@ import {
16
17
  import { requireForTenant } from "../../shared/index.js";
17
18
  import type { ContentFormat, TemplateKind } from "../constants.js";
18
19
  import type { TemplateWriteScope } from "../layout-regions.js";
20
+ import { TemplateResolverErrors } from "../qualified-names.js";
19
21
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
20
22
  import { activationChanges, serializeTemplateDraft } from "../template-draft.js";
23
+ import { isSystemOnlyTemplate } from "../template-system-only.js";
21
24
  import type { TemplateValidation } from "../template-validation.js";
22
25
  import { executor } from "./shared.js";
23
26
  import {
@@ -71,6 +74,21 @@ export function refuseTenantScopeUnderSystemTenant(
71
74
  );
72
75
  }
73
76
 
77
+ export function refuseSystemOnlyTenantOverride(
78
+ registry: Registry,
79
+ scope: TemplateWriteScope,
80
+ slug: string,
81
+ kind: string,
82
+ ): WriteFailure | null {
83
+ if (scope !== "tenant" || !isSystemOnlyTemplate(registry, slug, kind)) return null;
84
+ return writeFailure(
85
+ new AccessDeniedError({
86
+ i18nKey: "templateResolver.errors.systemOnlyTemplate",
87
+ details: { reason: TemplateResolverErrors.systemOnlyTemplate },
88
+ }),
89
+ );
90
+ }
91
+
74
92
  export type GatedTemplateWrite =
75
93
  | { readonly ok: true; readonly evaluated: Extract<TemplateWriteResult, { ok: true }> }
76
94
  | { readonly ok: false; readonly failure: WriteFailure };
@@ -141,7 +159,9 @@ export async function writeTemplateRow(
141
159
  args: TemplateRowWrite,
142
160
  ): Promise<WriteResult<WrittenTemplateRow>> {
143
161
  const { ctx, user, tenantId, scope, status, payload } = args;
144
- const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
162
+ const refused =
163
+ refuseTenantScopeUnderSystemTenant(scope, tenantId) ??
164
+ refuseSystemOnlyTenantOverride(ctx.registry, scope, payload.slug, payload.kind);
145
165
  if (refused !== null) return refused;
146
166
  const { db, streamTenantId } = requireForTenant(ctx, tenantId);
147
167
 
@@ -46,15 +46,18 @@ export {
46
46
  type DocumentViolation,
47
47
  type DocumentViolationCode,
48
48
  documentViolationsToFieldIssues,
49
+ flattenParagraphsToLineBreaks,
49
50
  type SanitizeBodyResult,
50
51
  type SanitizeHtmlResult,
51
52
  sanitizeDocumentHtml,
52
53
  sanitizeTemplateBody,
53
54
  } from "./document-sanitizer.js";
54
55
  export {
56
+ DOCUMENT_CONDITION_ATTRIBUTE,
55
57
  DOCUMENT_NODE_NAMES,
56
58
  DOCUMENT_TEXT_ALIGNMENTS,
57
59
  DocumentButton,
60
+ DocumentCondition,
58
61
  DocumentDivider,
59
62
  DocumentLink,
60
63
  DocumentPageBreak,
@@ -144,6 +147,7 @@ export {
144
147
  } from "./template-meta.js";
145
148
  export { type DocumentDraftOutcome, evaluateDocumentDraft } from "./template-preview.js";
146
149
  export { readTemplateResource, type TemplateResourceFile } from "./template-resources.js";
150
+ export { isSystemOnlyTemplate } from "./template-system-only.js";
147
151
  export { collectTemplateUsage, type TemplateUsageEntry } from "./template-usage.js";
148
152
  export {
149
153
  hasPlaceholderProblems,
@@ -60,6 +60,7 @@ export const TemplateResolverErrors = {
60
60
  alreadyArchived: "template_already_archived",
61
61
  targetExists: "target_exists",
62
62
  systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
63
+ systemOnlyTemplate: "system_only_template",
63
64
  bundleLocalesDiffer: "bundle_locales_differ",
64
65
  resourceTooLarge: "template_resource_too_large",
65
66
  resourceUnsupportedImage: "template_resource_unsupported_image",
@@ -28,11 +28,13 @@ import {
28
28
  getStreamVersion,
29
29
  getUnscopedStreamMaxVersionForSeed,
30
30
  } from "@cosmicdrift/kumiko-framework/event-store";
31
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
31
32
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
33
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
32
34
  import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants.js";
33
35
  import { executor } from "./handlers/shared.js";
34
36
  import { evaluateTemplateWrite } from "./handlers/template-gates.js";
35
- import { TEMPLATE_RESOLVER_FEATURE } from "./qualified-names.js";
37
+ import { TEMPLATE_RESOLVER_FEATURE, TemplateResolverHandlers } from "./qualified-names.js";
36
38
  import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
37
39
  import type { TemplateBundle } from "./template-bundle.js";
38
40
  import { activeStampChanges, servedStampChanges } from "./template-draft.js";
@@ -233,20 +235,21 @@ export async function seedSystemTemplate(
233
235
  export type SeedSystemTemplateBundleOptions = {
234
236
  readonly by?: SessionUser;
235
237
  readonly ifExists?: SeedIfExists;
238
+ /** Required for bundles with resources (e.g. a `logo`): the files go through the
239
+ * import-bundle-system write, which needs the file provider of the system tenant. */
240
+ readonly dispatcher?: Dispatcher;
236
241
  };
237
242
 
238
- // Seeds one system row per bundle locale. Bundled resources need a file
239
- // provider and the system tenant's file space, which a boot seed does not
240
- // have, so those go through the import-bundle-system write instead.
243
+ // Seeds one system row per bundle locale. Bundled resources need a file provider and
244
+ // the system tenant's file space, so those bundles are written through the
245
+ // import-bundle-system handler on the seed dispatcher instead of directly.
241
246
  export async function seedSystemTemplateBundle(
242
247
  db: DbConnection,
243
248
  bundle: TemplateBundle,
244
249
  opts: SeedSystemTemplateBundleOptions = {},
245
250
  ): Promise<{ ids: Record<string, string> }> {
246
251
  if (Object.keys(bundle.resources).length > 0) {
247
- throw new Error(
248
- `seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}: bundles with resources cannot be seeded; use the import-bundle-system write`,
249
- );
252
+ return seedSystemTemplateBundleWithResources(db, bundle, opts);
250
253
  }
251
254
  const ids: Record<string, string> = {};
252
255
  for (const [locale, entry] of Object.entries(bundle.locales)) {
@@ -266,6 +269,50 @@ export async function seedSystemTemplateBundle(
266
269
  return { ids };
267
270
  }
268
271
 
272
+ async function seedSystemTemplateBundleWithResources(
273
+ db: DbConnection,
274
+ bundle: TemplateBundle,
275
+ opts: SeedSystemTemplateBundleOptions,
276
+ ): Promise<{ ids: Record<string, string> }> {
277
+ const label = `seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}`;
278
+ const { dispatcher } = opts;
279
+ if (!dispatcher) {
280
+ throw new Error(
281
+ `${label}: bundles with resources need a file provider; pass opts.dispatcher (the dispatcher of the seed deps) so the files are stored through the import-bundle-system write`,
282
+ );
283
+ }
284
+ if ((opts.ifExists ?? "skip") === "skip") {
285
+ const existingIds: Record<string, string> = {};
286
+ for (const locale of Object.keys(bundle.locales)) {
287
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
288
+ tenantId: SYSTEM_TENANT_ID,
289
+ slug: bundle.slug,
290
+ kind: bundle.kind,
291
+ locale,
292
+ });
293
+ if (row) existingIds[locale] = String(row.id);
294
+ }
295
+ if (Object.keys(existingIds).length === Object.keys(bundle.locales).length) {
296
+ return { ids: existingIds };
297
+ }
298
+ }
299
+ const result = await dispatcher.write(
300
+ TemplateResolverHandlers.importBundleSystem,
301
+ { bundle },
302
+ opts.by ?? createSystemUser(SYSTEM_TENANT_ID, ["SystemAdmin"]),
303
+ );
304
+ if (!result.isSuccess) {
305
+ throw new Error(`${label} failed: ${result.error.code}: ${result.error.message}`);
306
+ }
307
+ return { ids: importedIds(result.data) };
308
+ }
309
+
310
+ function importedIds(data: unknown): Record<string, string> {
311
+ const ids = isPlainObject(data) ? data["ids"] : undefined;
312
+ if (!isPlainObject(ids)) throw new Error("seedSystemTemplateBundle: import returned no ids");
313
+ return Object.fromEntries(Object.entries(ids).map(([locale, id]) => [locale, String(id)]));
314
+ }
315
+
269
316
  export function hasNotificationTemplates(registry: Registry): boolean {
270
317
  return Array.from(registry.getAllNotifications().values()).some(
271
318
  (notification) => notification.template !== undefined,
@@ -69,6 +69,8 @@ function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
69
69
  return 0;
70
70
  case "date":
71
71
  return SAMPLE_DATE;
72
+ case "percent":
73
+ return 0;
72
74
  }
73
75
  }
74
76
 
@@ -0,0 +1,21 @@
1
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { isSystemAdmin } from "./template-draft.js";
3
+
4
+ const NOTIFICATION_TEMPLATE_KIND = "mail";
5
+
6
+ // A notification registered with systemOnly keeps its mail template as the system default:
7
+ // tenants neither list nor override it, and overrides stored before the flag are ignored.
8
+ export function isSystemOnlyTemplate(registry: Registry, slug: string, kind: string): boolean {
9
+ return (
10
+ kind === NOTIFICATION_TEMPLATE_KIND &&
11
+ registry.getAllNotifications().get(slug)?.systemOnly === true
12
+ );
13
+ }
14
+
15
+ export function isSystemOnlyTemplateHiddenFrom(
16
+ user: { readonly roles: readonly string[] },
17
+ registry: Registry,
18
+ row: { readonly slug: string; readonly kind: string },
19
+ ): boolean {
20
+ return !isSystemAdmin(user) && isSystemOnlyTemplate(registry, row.slug, row.kind);
21
+ }