@cosmicdrift/kumiko-samples 0.366.0 → 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 (42) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  4. package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
  5. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  6. package/packages/bundled-features/src/delivery/changes.json +12 -0
  7. package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
  8. package/packages/bundled-features/src/delivery/delivery-service.ts +54 -2
  9. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  10. package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +10 -5
  11. package/packages/bundled-features/src/delivery/index.ts +6 -0
  12. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  13. package/packages/bundled-features/src/delivery/types.ts +4 -0
  14. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
  15. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
  16. package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
  17. package/packages/bundled-features/src/mail-foundation/feature.ts +50 -9
  18. package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
  19. package/packages/bundled-features/src/template-resolver/changes.json +25 -0
  20. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
  21. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
  22. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
  23. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
  24. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
  25. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
  26. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
  27. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
  28. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
  29. package/packages/bundled-features/src/template-resolver/index.ts +4 -0
  30. package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
  31. package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
  32. package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
  33. package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
  34. package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
  35. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
  36. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  37. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  38. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
  39. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
  40. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
  41. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  42. package/samples/apps/use-all-bundled/package.json +1 -0
@@ -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
+ }
@@ -6,8 +6,12 @@ import type {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import type { Element as ScanElement } from "happy-dom";
8
8
  import * as z from "zod";
9
- import { withParsedDocument } from "./document-sanitizer.js";
10
- import { isDocumentPlaceholderHref, isDocumentReferenceName } from "./document-schema.js";
9
+ import { conditionScopeElement, withParsedDocument } from "./document-sanitizer.js";
10
+ import {
11
+ DOCUMENT_CONDITION_ATTRIBUTE,
12
+ isDocumentPlaceholderHref,
13
+ isDocumentReferenceName,
14
+ } from "./document-schema.js";
11
15
  import type { TemplateMeta } from "./template-meta.js";
12
16
 
13
17
  export const SYSTEM_PLACEHOLDERS: ReadonlySet<string> = new Set([
@@ -56,6 +60,7 @@ const scalarVariableSchema = z.discriminatedUnion("type", [
56
60
  .optional(),
57
61
  }),
58
62
  z.object({ ...variableCommon, type: z.literal("date"), example: textLikeExample }),
63
+ z.object({ ...variableCommon, type: z.literal("percent"), example: z.number().optional() }),
59
64
  ]);
60
65
 
61
66
  const variableDefSchema = z.union([
@@ -154,30 +159,49 @@ const NAME_ATTRIBUTE_BY_TAG: Readonly<Record<string, string>> = {
154
159
  div: "data-steps",
155
160
  };
156
161
 
162
+ function scanRepeatRows(root: ScanElement, scope: PlaceholderScope): void {
163
+ for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
164
+ const listName = element.getAttribute("data-each") ?? "";
165
+ const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
166
+ if (def?.type === "list") scope.used.add(listName);
167
+ else scope.unknown.add(listName);
168
+ }
169
+ }
170
+
171
+ function scanConditions(root: ScanElement, scope: PlaceholderScope): void {
172
+ for (const element of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
173
+ const itemFields = listItemFields(
174
+ scope.variables,
175
+ enclosingEachName(conditionScopeElement(element)),
176
+ );
177
+ recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope);
178
+ }
179
+ }
180
+
181
+ function scanReferences(root: ScanElement, scope: PlaceholderScope): void {
182
+ for (const element of Array.from(
183
+ root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
184
+ )) {
185
+ const itemFields = listItemFields(scope.variables, enclosingEachName(element));
186
+ const tag = element.tagName.toLowerCase();
187
+ const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
188
+ if (nameAttribute !== undefined) {
189
+ recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
190
+ } else if (tag === "a") {
191
+ const href = element.getAttribute("href") ?? "";
192
+ if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
193
+ } else {
194
+ const key = element.getAttribute("data-resource") ?? "";
195
+ if (!scope.resourceKeys.has(key)) scope.unknownResources.add(key);
196
+ }
197
+ }
198
+ }
199
+
157
200
  function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
158
201
  withParsedDocument(body, (root) => {
159
- for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
160
- const listName = element.getAttribute("data-each") ?? "";
161
- const def = Object.hasOwn(scope.variables, listName) ? scope.variables[listName] : undefined;
162
- if (def?.type === "list") scope.used.add(listName);
163
- else scope.unknown.add(listName);
164
- }
165
- for (const element of Array.from(
166
- root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
167
- )) {
168
- const itemFields = listItemFields(scope.variables, enclosingEachName(element));
169
- const tag = element.tagName.toLowerCase();
170
- const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
171
- if (nameAttribute !== undefined) {
172
- recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
173
- } else if (tag === "a") {
174
- const href = element.getAttribute("href") ?? "";
175
- if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
176
- } else {
177
- const key = element.getAttribute("data-resource") ?? "";
178
- if (!scope.resourceKeys.has(key)) scope.unknownResources.add(key);
179
- }
180
- }
202
+ scanRepeatRows(root, scope);
203
+ scanConditions(root, scope);
204
+ scanReferences(root, scope);
181
205
  });
182
206
  }
183
207
 
@@ -96,6 +96,8 @@ export const defaultTranslations: TranslationsByLocale = {
96
96
  "“{detail}” templates are written through their own editor, not through a collection.",
97
97
  "templateResolver.errors.noSystemTemplate": "There is no active system template to reset to.",
98
98
  "templateResolver.errors.noDraft": "There is no draft to discard.",
99
+ "templateResolver.errors.systemOnlyTemplate":
100
+ "Only the platform maintains this template; it cannot be overridden per tenant.",
99
101
  "templateResolver.errors.template.invalidMeta": "The template settings are invalid.",
100
102
  "templateResolver.errors.template.meta.subject.required": "The subject is missing.",
101
103
  "templateResolver.errors.template.meta.subject.invalid":
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "fix",
5
+ "title": "Tests seed tenant memberships and events through the framework",
6
+ "detail": "Test-only change. Integration tests create read_tenant_memberships from tenantMembershipEntity and seed rows with seedRow instead of hand-written DDL and raw INSERTs. Fake events go through the event-store append, and backdated audit events are written with runSeedWritesAt instead of an UPDATE on created_at. No runtime behavior changes."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "improvement",
@@ -32,6 +32,7 @@ import {
32
32
  SYSTEM_INVOICE_ISSUED,
33
33
  SYSTEM_PDF_LAYOUT,
34
34
  SYSTEM_TENANCY_CERTIFICATE,
35
+ SYSTEM_TERMS_CHANGED,
35
36
  TENANT_INVOICE_OVERRIDES,
36
37
  TENANT_MAIL_LAYOUT_VERSIONS,
37
38
  TENANT_UTILITY_STATEMENT,
@@ -165,6 +166,7 @@ export const seedTemplates: SeedFn = async (stack) => {
165
166
  await upsertSystem(SYSTEM_PDF_LAYOUT);
166
167
  for (const mail of SYSTEM_AUTH_MAILS) await upsertSystem(mail);
167
168
  await upsertSystem(SYSTEM_INVOICE_ISSUED);
169
+ await upsertSystem(SYSTEM_TERMS_CHANGED);
168
170
  for (const document of SYSTEM_INVOICE_DOCUMENTS) await upsertSystem(document);
169
171
  const certificate = await upsertSystem(SYSTEM_TENANCY_CERTIFICATE);
170
172
  await stack.http.writeOk(
@@ -276,6 +276,18 @@ export const INVOICE_CORRECTED_DRAFT: SeedTemplate = {
276
276
  meta: invoiceIssuedMeta("Ihre Rechnung {{invoiceNumber}} von {{tenantName}}"),
277
277
  };
278
278
 
279
+ export const SYSTEM_TERMS_CHANGED: SeedTemplate = {
280
+ slug: "billing:notify:terms-changed",
281
+ kind: "mail",
282
+ locale: "de",
283
+ title: "AGB-Änderung",
284
+ content: [
285
+ `<p>Guten Tag ${v("customerName")},</p>`,
286
+ `<p>zum ${v("effectiveDate")} ändern sich die Allgemeinen Geschäftsbedingungen des Mieterportals.</p>`,
287
+ ].join(""),
288
+ meta: { subject: "Änderung der Geschäftsbedingungen" },
289
+ };
290
+
279
291
  export const PAYMENT_REMINDER_TEMPLATES: readonly SeedTemplate[] = [
280
292
  {
281
293
  slug: "billing:notify:payment-reminder",
@@ -63,10 +63,17 @@ const PAYMENT_REMINDER_VARIABLES = {
63
63
  invoiceUrl: templateVariable.link({ required: true }),
64
64
  } as const;
65
65
 
66
+ const TERMS_CHANGED_VARIABLES = {
67
+ customerName: templateVariable.text({ required: true, example: "Familie Yilmaz" }),
68
+ effectiveDate: templateVariable.date({ required: true, example: "2026-12-01" }),
69
+ } as const;
70
+
66
71
  export const billingFeature = defineFeature("billing", (r) => {
67
72
  r.translations({ keys: toKeyFirst(billingTranslations) });
68
73
 
69
74
  r.notification("invoice-issued", { variables: INVOICE_ISSUED_VARIABLES });
70
75
  r.notification("invoice-corrected", { variables: INVOICE_ISSUED_VARIABLES });
71
76
  r.notification("payment-reminder", { variables: PAYMENT_REMINDER_VARIABLES });
77
+ // Legal wording the platform owns: tenant admins neither see nor override it.
78
+ r.notification("terms-changed", { variables: TERMS_CHANGED_VARIABLES, systemOnly: true });
72
79
  });
@@ -8,11 +8,13 @@ export const billingTranslations: TranslationsByLocale = {
8
8
  "billing:notify.invoice-issued.label": "Rechnung gestellt",
9
9
  "billing:notify.invoice-corrected.label": "Rechnung korrigiert",
10
10
  "billing:notify.payment-reminder.label": "Mahnlauf",
11
+ "billing:notify.terms-changed.label": "AGB-Änderung",
11
12
  },
12
13
  en: {
13
14
  "billing:feature.label": "Invoices",
14
15
  "billing:notify.invoice-issued.label": "Invoice issued",
15
16
  "billing:notify.invoice-corrected.label": "Invoice corrected",
16
17
  "billing:notify.payment-reminder.label": "Dunning run",
18
+ "billing:notify.terms-changed.label": "Terms update",
17
19
  },
18
20
  };
@@ -407,7 +407,7 @@
407
407
  },
408
408
  {
409
409
  "name": "channel-email",
410
- "description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.",
410
+ "description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
411
411
  "toggleableDefault": null,
412
412
  "requires": [
413
413
  "delivery"
@@ -725,7 +725,7 @@
725
725
  },
726
726
  {
727
727
  "name": "delivery",
728
- "description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route.",
728
+ "description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
729
729
  "toggleableDefault": null,
730
730
  "requires": [
731
731
  "tenant"
@@ -1507,7 +1507,7 @@
1507
1507
  },
1508
1508
  {
1509
1509
  "name": "mail-foundation",
1510
- "description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences.",
1510
+ "description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.",
1511
1511
  "toggleableDefault": null,
1512
1512
  "requires": [
1513
1513
  "config"
@@ -2122,7 +2122,7 @@
2122
2122
  },
2123
2123
  {
2124
2124
  "name": "renderer-mail-html",
2125
- "description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.",
2125
+ "description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource=\"logo\"` images use the branding logo upload, or the branding logo https address when no file is uploaded. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.",
2126
2126
  "toggleableDefault": null,
2127
2127
  "requires": [
2128
2128
  "renderer-foundation",
@@ -21,6 +21,7 @@
21
21
  "@cosmicdrift/kumiko-document-designer": "workspace:*",
22
22
  "@cosmicdrift/kumiko-framework": "workspace:*",
23
23
  "@cosmicdrift/kumiko-headless": "workspace:*",
24
+ "@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
24
25
  "@cosmicdrift/kumiko-renderer-puppeteer-client": "workspace:*",
25
26
  "@cosmicdrift/kumiko-renderer-web": "workspace:*",
26
27
  "@cosmicdrift/kumiko-server-runtime": "workspace:*",