@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -4,13 +4,23 @@ import * as z from "zod";
4
4
  import { FALLBACK_LOCALE } from "../constants.js";
5
5
  import { type LayoutRegions, readLayoutRegions } from "../layout-regions.js";
6
6
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
7
+ import {
8
+ type ActiveInfo,
9
+ draftViewerTenantId,
10
+ isHiddenSystemDraft,
11
+ toWorkingRow,
12
+ type WorkingRow,
13
+ } from "../template-draft.js";
14
+ import { type MailLayoutMeta, readMailLayoutMeta } from "../template-meta.js";
7
15
  import { localeSchema, templateSlugSchema } from "./shared.js";
16
+ import { parseStoredJson } from "./template-gates.js";
8
17
 
9
18
  export type LayoutSourceRow = {
10
19
  readonly id: string;
11
20
  readonly version: number;
12
21
  readonly status: string;
13
22
  readonly locale: string;
23
+ readonly active: ActiveInfo | null;
14
24
  };
15
25
 
16
26
  export type LayoutSourceResponse = {
@@ -19,6 +29,8 @@ export type LayoutSourceResponse = {
19
29
  readonly regions: LayoutRegions;
20
30
  readonly effectiveFormat: "html" | "mjml" | null;
21
31
  readonly mjmlSource: string | null;
32
+ /** Appearance of the effective mail layout; invalid stored meta reads as defaults (empty). */
33
+ readonly meta: MailLayoutMeta;
22
34
  readonly locale: string;
23
35
  };
24
36
 
@@ -38,33 +50,82 @@ function pickByLocale(
38
50
  );
39
51
  }
40
52
 
41
- function toSourceRow(row: TemplateResourceRow): LayoutSourceRow {
42
- return { id: String(row.id), version: row.version, status: row.status, locale: row.locale };
53
+ function toSourceRow(row: WorkingRow): LayoutSourceRow {
54
+ return {
55
+ id: String(row.id),
56
+ version: row.version,
57
+ status: row.status,
58
+ locale: row.locale,
59
+ active: row.active,
60
+ };
43
61
  }
44
62
 
45
- function layoutFormat(row: TemplateResourceRow): "html" | "mjml" | null {
63
+ function layoutFormat(row: WorkingRow): "html" | "mjml" | null {
46
64
  return row.contentFormat === "html" || row.contentFormat === "mjml" ? row.contentFormat : null;
47
65
  }
48
66
 
49
67
  function pickEditedRows(
50
68
  rows: readonly TemplateResourceRow[],
51
- isSystemAdmin: boolean,
69
+ user: { readonly tenantId: string; readonly roles: readonly string[] },
52
70
  locale: string | undefined,
53
71
  ): {
54
- readonly tenantRow: TemplateResourceRow | null;
55
- readonly systemRow: TemplateResourceRow | null;
72
+ readonly tenantRow: WorkingRow | null;
73
+ readonly systemRow: WorkingRow | null;
56
74
  } {
57
- const tenantRow = isSystemAdmin
75
+ const tenantRow = user.roles.includes("SystemAdmin")
58
76
  ? null
59
77
  : pickByLocale(
60
78
  rows.filter((row) => row.scope === "tenant"),
61
79
  locale,
62
80
  );
63
81
  const systemRow = pickByLocale(
64
- rows.filter((row) => row.scope === "system"),
82
+ rows.filter((row) => row.scope === "system" && !isHiddenSystemDraft(user, row)),
65
83
  locale,
66
84
  );
67
- return { tenantRow, systemRow };
85
+ return {
86
+ tenantRow: tenantRow && toWorkingRow(tenantRow, user.tenantId),
87
+ // Only a SystemAdmin works on the system draft; a tenant sees the served system version.
88
+ systemRow: systemRow && toWorkingRow(systemRow, draftViewerTenantId(user, systemRow)),
89
+ };
90
+ }
91
+
92
+ function readEffectiveRegions(
93
+ effective: WorkingRow | null,
94
+ effectiveFormat: "html" | "mjml" | null,
95
+ ): LayoutRegions {
96
+ return effective !== null && effectiveFormat === "html"
97
+ ? readLayoutRegions(effective.content ?? "")
98
+ : EMPTY_REGIONS;
99
+ }
100
+
101
+ function readMjmlSource(systemRow: WorkingRow | null, isSystemAdmin: boolean): string | null {
102
+ const exposesMjml = isSystemAdmin && systemRow !== null && layoutFormat(systemRow) === "mjml";
103
+ return exposesMjml ? (systemRow.content ?? "") : null;
104
+ }
105
+
106
+ function buildLayoutSource(input: {
107
+ readonly kind: "mail-layout" | "pdf-layout";
108
+ readonly locale: string | undefined;
109
+ readonly isSystemAdmin: boolean;
110
+ readonly tenantRow: WorkingRow | null;
111
+ readonly systemRow: WorkingRow | null;
112
+ }): LayoutSourceResponse {
113
+ const { kind, locale, isSystemAdmin, tenantRow, systemRow } = input;
114
+ const effective = tenantRow ?? systemRow;
115
+ const effectiveFormat = effective === null ? null : layoutFormat(effective);
116
+ return {
117
+ tenant: tenantRow === null ? null : toSourceRow(tenantRow),
118
+ system:
119
+ systemRow === null
120
+ ? null
121
+ : { ...toSourceRow(systemRow), contentFormat: systemRow.contentFormat },
122
+ regions: readEffectiveRegions(effective, effectiveFormat),
123
+ effectiveFormat,
124
+ mjmlSource: readMjmlSource(systemRow, isSystemAdmin),
125
+ meta:
126
+ kind === "mail-layout" ? readMailLayoutMeta(parseStoredJson(effective?.meta ?? null)) : {},
127
+ locale: effective?.locale ?? locale ?? FALLBACK_LOCALE,
128
+ };
68
129
  }
69
130
 
70
131
  export const layoutSourceQuery = defineQueryHandler({
@@ -76,7 +137,7 @@ export const layoutSourceQuery = defineQueryHandler({
76
137
  }),
77
138
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
78
139
  description:
79
- "Reads the layout the caller edits: the tenant's own version and the system version of one layout slug, the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version and also receives its mjml source. Use it to load the layout editor.",
140
+ "Reads the layout the caller edits: the tenant's own version and the system version of one layout slug (each the open draft if there is one, else the stored version, with `active` naming the served version), the header, signature and footer regions of the effective html version, and the locale a save has to use; a SystemAdmin edits the system version and also receives its mjml source. Use it to load the layout editor.",
80
141
  handler: async (query, ctx): Promise<LayoutSourceResponse> => {
81
142
  const { kind, slug, locale } = query.payload;
82
143
  const isSystemAdmin = query.user.roles.includes("SystemAdmin");
@@ -84,25 +145,7 @@ export const layoutSourceQuery = defineQueryHandler({
84
145
  kind,
85
146
  slug,
86
147
  });
87
- const { tenantRow, systemRow } = pickEditedRows(rows, isSystemAdmin, locale);
88
- const effective = tenantRow ?? systemRow;
89
- const effectiveFormat = effective === null ? null : layoutFormat(effective);
90
- return {
91
- tenant: tenantRow === null ? null : toSourceRow(tenantRow),
92
- system:
93
- systemRow === null
94
- ? null
95
- : { ...toSourceRow(systemRow), contentFormat: systemRow.contentFormat },
96
- regions:
97
- effective !== null && effectiveFormat === "html"
98
- ? readLayoutRegions(effective.content ?? "")
99
- : EMPTY_REGIONS,
100
- effectiveFormat,
101
- mjmlSource:
102
- isSystemAdmin && systemRow !== null && layoutFormat(systemRow) === "mjml"
103
- ? (systemRow.content ?? "")
104
- : null,
105
- locale: effective?.locale ?? locale ?? FALLBACK_LOCALE,
106
- };
148
+ const { tenantRow, systemRow } = pickEditedRows(rows, query.user, locale);
149
+ return buildLayoutSource({ kind, locale, isSystemAdmin, tenantRow, systemRow });
107
150
  },
108
151
  });
@@ -3,6 +3,7 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  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
+ import { isHiddenSystemDraft } from "../template-draft.js";
6
7
 
7
8
  // ctx.db is tenant-scoped even for SystemAdmin: every caller sees its own
8
9
  // tenant's rows plus the system defaults, never another tenant's rows.
@@ -43,16 +44,18 @@ export const listQuery = defineQueryHandler({
43
44
  limit: 500,
44
45
  })) as TemplateResourceRow[];
45
46
 
46
- return rows.map((row) => ({
47
- id: String(row.id),
48
- tenantId: row.tenantId,
49
- slug: row.slug,
50
- kind: row.kind,
51
- locale: row.locale,
52
- scope: row.scope,
53
- status: row.status,
54
- contentFormat: row.contentFormat,
55
- modifiedAt: row.modifiedAt,
56
- }));
47
+ return rows
48
+ .filter((row) => !isHiddenSystemDraft(query.user, row))
49
+ .map((row) => ({
50
+ id: String(row.id),
51
+ tenantId: row.tenantId,
52
+ slug: row.slug,
53
+ kind: row.kind,
54
+ locale: row.locale,
55
+ scope: row.scope,
56
+ status: row.status,
57
+ contentFormat: row.contentFormat,
58
+ modifiedAt: row.modifiedAt,
59
+ }));
57
60
  },
58
61
  });
@@ -0,0 +1,49 @@
1
+ import type { TemplateResourceRow } from "../table.js";
2
+ import { diffTemplateBlocks, splitTemplateBlocks } from "../template-diff.js";
3
+ import { servedVersionOf } from "../template-draft.js";
4
+
5
+ function parseStoredMeta(raw: string | null): Readonly<Record<string, unknown>> {
6
+ if (raw === null) return {};
7
+ try {
8
+ const parsed: unknown = JSON.parse(raw);
9
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
10
+ ? Object.fromEntries(Object.entries(parsed))
11
+ : {};
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ function diffMeta(
18
+ system: Readonly<Record<string, unknown>>,
19
+ tenant: Readonly<Record<string, unknown>>,
20
+ ) {
21
+ const keys = [...new Set([...Object.keys(system), ...Object.keys(tenant)])].sort();
22
+ return keys
23
+ .filter((key) => JSON.stringify(system[key]) !== JSON.stringify(tenant[key]))
24
+ .map((key) => ({ key, tenant: tenant[key], system: system[key] }));
25
+ }
26
+
27
+ // Shared by the tenant's own diff and the SystemAdmin's per-tenant diff in the overrides drawer.
28
+ export function buildOverrideDiff(tenantRow: TemplateResourceRow, systemRow: TemplateResourceRow) {
29
+ const blocks = diffTemplateBlocks(
30
+ splitTemplateBlocks(systemRow.content ?? "", systemRow.contentFormat),
31
+ splitTemplateBlocks(tenantRow.content ?? "", tenantRow.contentFormat),
32
+ );
33
+ const meta = diffMeta(parseStoredMeta(systemRow.meta), parseStoredMeta(tenantRow.meta));
34
+ return {
35
+ tenant: {
36
+ templateId: String(tenantRow.id),
37
+ templateVersion: servedVersionOf(tenantRow),
38
+ contentFormat: tenantRow.contentFormat,
39
+ },
40
+ system: {
41
+ templateId: String(systemRow.id),
42
+ templateVersion: servedVersionOf(systemRow),
43
+ contentFormat: systemRow.contentFormat,
44
+ },
45
+ identical: meta.length === 0 && blocks.every((block) => block.change === "same"),
46
+ blocks,
47
+ meta,
48
+ };
49
+ }
@@ -3,7 +3,15 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
4
  import { DEFAULT_MAIL_LAYOUT_SLUG, FALLBACK_LOCALE, type TemplateStatus } from "../constants.js";
5
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
6
- import { collectTemplateUsage } from "../template-usage.js";
6
+ import {
7
+ activeSinceOf,
8
+ activeVersionOf,
9
+ draftViewerTenantId,
10
+ isHiddenSystemDraft,
11
+ parseTemplateDraft,
12
+ servedVersionOf,
13
+ } from "../template-draft.js";
14
+ import { collectTemplateUsage, type EffectiveMailLayoutSlugs } from "../template-usage.js";
7
15
  import { storedRecord } from "./template-gates.js";
8
16
 
9
17
  const OVERVIEW_KINDS = ["mail", "document-pdf", "mail-layout", "pdf-layout"] as const;
@@ -31,6 +39,8 @@ export type OverviewRow = {
31
39
  readonly locales: readonly string[];
32
40
  readonly status: TemplateStatus;
33
41
  readonly version: number;
42
+ readonly draftVersion: number | null;
43
+ readonly activeSince: string | null;
34
44
  readonly systemVersion: number | null;
35
45
  readonly layoutSlug: string | null;
36
46
  readonly modifiedAt: string;
@@ -89,6 +99,27 @@ function effectiveRowsOf(rows: readonly TemplateResourceRow[]): EffectiveRows |
89
99
  return tenantRows.length > 0 ? { origin: "tenant", rows: tenantRows } : null;
90
100
  }
91
101
 
102
+ export function effectiveMailLayoutSlugs(
103
+ rows: readonly TemplateResourceRow[],
104
+ ): EffectiveMailLayoutSlugs {
105
+ const byLocale = new Map<string, TemplateResourceRow[]>();
106
+ for (const row of rows) {
107
+ if (row.kind !== "mail") continue;
108
+ const key = `${row.slug}\u0000${row.locale}`;
109
+ byLocale.set(key, [...(byLocale.get(key) ?? []), row]);
110
+ }
111
+ const bySlug = new Map<string, Map<string, string>>();
112
+ for (const group of byLocale.values()) {
113
+ const effective = effectiveRowsOf(group)?.rows[0];
114
+ // All-archived: the renderer falls back to the code-registered template.
115
+ if (effective === undefined || effective.status === "archived") continue;
116
+ const locales = bySlug.get(effective.slug) ?? new Map<string, string>();
117
+ locales.set(effective.locale, layoutSlugOf(effective));
118
+ bySlug.set(effective.slug, locales);
119
+ }
120
+ return bySlug;
121
+ }
122
+
92
123
  function modifiedAtOf(row: TemplateResourceRow): string {
93
124
  return (row.modifiedAt ?? row.insertedAt).toString();
94
125
  }
@@ -126,7 +157,37 @@ function groupBySlugAndKind(
126
157
  return groups;
127
158
  }
128
159
 
129
- function buildDrafts(groups: ReadonlyMap<string, readonly TemplateResourceRow[]>): {
160
+ // Another tenant's draft, including a system draft seen by a tenant, is never announced.
161
+ function draftVersionFor(
162
+ user: { readonly tenantId: string; readonly roles: readonly string[] },
163
+ row: TemplateResourceRow,
164
+ ): number | null {
165
+ const isOwnRow = row.tenantId === draftViewerTenantId(user, row);
166
+ return isOwnRow && parseTemplateDraft(row.draft) !== null ? row.version : null;
167
+ }
168
+
169
+ // Version columns of one overview row: the served version, a waiting draft and the system version it overrides.
170
+ function versionFieldsOf(
171
+ user: { readonly tenantId: string; readonly roles: readonly string[] },
172
+ preferred: TemplateResourceRow,
173
+ groupRows: readonly TemplateResourceRow[],
174
+ ): Pick<OverviewDraft, "version" | "draftVersion" | "activeSince" | "systemVersion"> {
175
+ const isActive = preferred.status === "active";
176
+ const systemSameLocale = groupRows.find(
177
+ (row) => row.scope === "system" && row.locale === preferred.locale,
178
+ );
179
+ return {
180
+ version: servedVersionOf(preferred),
181
+ draftVersion: draftVersionFor(user, preferred),
182
+ activeSince: isActive ? activeSinceOf(preferred).toString() : null,
183
+ systemVersion: systemSameLocale === undefined ? null : activeVersionOf(systemSameLocale),
184
+ };
185
+ }
186
+
187
+ function buildDrafts(
188
+ groups: ReadonlyMap<string, readonly TemplateResourceRow[]>,
189
+ user: { readonly tenantId: string; readonly roles: readonly string[] },
190
+ ): {
130
191
  readonly drafts: OverviewDraft[];
131
192
  readonly layoutSlugByDocument: Map<string, string>;
132
193
  } {
@@ -137,9 +198,6 @@ function buildDrafts(groups: ReadonlyMap<string, readonly TemplateResourceRow[]>
137
198
  const preferred = effective === null ? undefined : preferredRow(effective.rows);
138
199
  if (effective === null || preferred === undefined) continue;
139
200
  if (!isOverviewKind(preferred.kind) || !isTemplateStatus(preferred.status)) continue;
140
- const systemSameLocale = groupRows.find(
141
- (row) => row.scope === "system" && row.locale === preferred.locale,
142
- );
143
201
  const isDocument = preferred.kind === "mail" || preferred.kind === "document-pdf";
144
202
  const layoutSlug = isDocument ? layoutSlugOf(preferred) : null;
145
203
  if (layoutSlug !== null && preferred.kind === "document-pdf") {
@@ -154,8 +212,7 @@ function buildDrafts(groups: ReadonlyMap<string, readonly TemplateResourceRow[]>
154
212
  origin: effective.origin,
155
213
  locales: [...new Set(effective.rows.map((row) => row.locale))].sort(),
156
214
  status: preferred.status,
157
- version: preferred.version,
158
- systemVersion: systemSameLocale?.version ?? null,
215
+ ...versionFieldsOf(user, preferred, groupRows),
159
216
  layoutSlug,
160
217
  modifiedAt: modifiedAtOf(preferred),
161
218
  });
@@ -167,15 +224,18 @@ function withUsage(
167
224
  draft: OverviewDraft,
168
225
  layoutSlugByDocument: ReadonlyMap<string, string>,
169
226
  registry: Parameters<typeof collectTemplateUsage>[0],
227
+ effectiveLayouts: EffectiveMailLayoutSlugs,
170
228
  ): OverviewRow {
171
229
  let usedBy: OverviewUsage[] = [];
172
230
  if (draft.kind === "mail" || draft.kind === "mail-layout") {
173
- usedBy = collectTemplateUsage(registry, draft.slug, draft.kind).map((entry) => ({
174
- qualifiedName: entry.qualifiedName,
175
- feature: entry.feature,
176
- notification: entry.notification,
177
- trigger: entry.trigger,
178
- }));
231
+ usedBy = collectTemplateUsage(registry, draft.slug, draft.kind, effectiveLayouts).map(
232
+ (entry) => ({
233
+ qualifiedName: entry.qualifiedName,
234
+ feature: entry.feature,
235
+ notification: entry.notification,
236
+ trigger: entry.trigger,
237
+ }),
238
+ );
179
239
  } else if (draft.kind === "pdf-layout") {
180
240
  usedBy = [...layoutSlugByDocument]
181
241
  .filter(([, layoutSlug]) => layoutSlug === draft.slug)
@@ -192,9 +252,11 @@ function filterRows(
192
252
  rows: readonly OverviewRow[],
193
253
  search: string | undefined,
194
254
  filters: readonly OverviewFilter[],
255
+ layoutSlug: string | undefined,
195
256
  ): OverviewRow[] {
196
257
  const needle = search?.trim().toLowerCase() ?? "";
197
258
  return rows.filter((row) => {
259
+ if (layoutSlug !== undefined && row.layoutSlug !== layoutSlug) return false;
198
260
  if (needle !== "" && !`${row.title} ${row.slug}`.toLowerCase().includes(needle)) return false;
199
261
  return filters.every((filter) => filter.value.includes(String(row[filter.field])));
200
262
  });
@@ -211,6 +273,7 @@ export const overviewQuery = defineQueryHandler({
211
273
  schema: z.object({
212
274
  search: z.string().optional(),
213
275
  filters: z.array(filterSchema).optional(),
276
+ layoutSlug: z.string().optional(),
214
277
  sort: z.string().optional(),
215
278
  sortDirection: z.enum(["asc", "desc"]).optional(),
216
279
  limit: z.number().int().min(1).max(200).default(50),
@@ -219,7 +282,7 @@ export const overviewQuery = defineQueryHandler({
219
282
  }),
220
283
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
221
284
  description:
222
- "Lists mail, PDF and layout templates as one row per slug and kind for the caller's tenant: where the effective version comes from (own override or system), its locales, status and version, 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.",
285
+ "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.",
223
286
  handler: async (query, ctx) => {
224
287
  const payload = query.payload;
225
288
  const rows = await selectMany<TemplateResourceRow>(
@@ -229,11 +292,21 @@ export const overviewQuery = defineQueryHandler({
229
292
  { limit: OVERVIEW_ROW_LIMIT },
230
293
  );
231
294
 
232
- const { drafts, layoutSlugByDocument } = buildDrafts(groupBySlugAndKind(rows));
295
+ const visibleRows = rows.filter((row) => !isHiddenSystemDraft(query.user, row));
296
+ const { drafts, layoutSlugByDocument } = buildDrafts(
297
+ groupBySlugAndKind(visibleRows),
298
+ query.user,
299
+ );
300
+ const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows);
233
301
  const overviewRows = drafts.map((draft) =>
234
- withUsage(draft, layoutSlugByDocument, ctx.registry),
302
+ withUsage(draft, layoutSlugByDocument, ctx.registry, effectiveLayouts),
303
+ );
304
+ const filtered = filterRows(
305
+ overviewRows,
306
+ payload.search,
307
+ payload.filters ?? [],
308
+ payload.layoutSlug,
235
309
  );
236
- const filtered = filterRows(overviewRows, payload.search, payload.filters ?? []);
237
310
 
238
311
  const sortField: SortField =
239
312
  payload.sort !== undefined && isSortField(payload.sort) ? payload.sort : "modifiedAt";
@@ -91,7 +91,7 @@ export const resourceUploadWrite = defineWriteHandler({
91
91
  try {
92
92
  source = await readFileRefContent({
93
93
  db: ctx.db,
94
- filesForTenant: (tid) => fileContextForTenant(ctx, tid),
94
+ filesForTenant: (tid) => fileContextForTenant(ctx, ctx.user.tenantId, tid),
95
95
  fileRefId: sourceFileRefId,
96
96
  allowedTenantIds: [event.user.tenantId],
97
97
  unclaimedUploadOf: event.user.id,
@@ -28,6 +28,11 @@ function testMailFailed(code: "channel_error" | "not_sent") {
28
28
  );
29
29
  }
30
30
 
31
+ function errorCodeOf(error: unknown): string | undefined {
32
+ const code = error instanceof Error ? Reflect.get(error, "code") : undefined;
33
+ return typeof code === "string" ? code : undefined;
34
+ }
35
+
31
36
  // The only recipient is the caller's own address, resolved server-side, so the handler can never mail a third party.
32
37
  export const sendTestWrite = defineWriteHandler({
33
38
  name: "send-test",
@@ -37,18 +42,24 @@ export const sendTestWrite = defineWriteHandler({
37
42
  content: z.string().max(200_000).optional(),
38
43
  meta: z.unknown().optional(),
39
44
  variableSchema: z.record(z.string(), z.unknown()).default({}),
45
+ includeAttachments: z.boolean().default(true),
46
+ prefixSubject: z.boolean().default(true),
40
47
  }),
41
48
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
42
49
  rateLimit: { per: "user+handler", ...TEST_MAIL_RATE_LIMIT },
43
50
  description:
44
- "Sends the rendered preview of a mail template draft, or of the stored template when no content is given, to the calling user's own address with the subject prefixed by [TEST], carrying the inline images and the files attached to the template; there is no recipient input, the sample values come from the declared variable examples, and a draft that fails the write rules is rejected without sending.",
51
+ "Sends the rendered preview of a mail template draft, or of the stored template when no content is given, to the calling user's own address with the subject prefixed by [TEST] unless prefixSubject is false, carrying the inline images and, unless includeAttachments is false, the files attached to the template; there is no recipient input, the sample values come from the declared variable examples, and a draft that fails the write rules is rejected without sending.",
45
52
  handler: async (event, ctx) => {
53
+ const { includeAttachments, prefixSubject, ...previewPayload } = event.payload;
46
54
  const outcome = await renderTemplatePreview(ctx, event.user, {
47
- ...event.payload,
55
+ ...previewPayload,
48
56
  kind: "mail",
49
57
  });
50
58
  if (!outcome.ok) return writeFailure(outcome.error);
51
- const { preview, mailAttachments } = outcome;
59
+ const { preview, sender } = outcome;
60
+ const mailAttachments = includeAttachments
61
+ ? outcome.mailAttachments
62
+ : outcome.mailAttachments.filter((ref) => ref.kind === "inline");
52
63
 
53
64
  const channel = emailChannelOf(ctx.registry);
54
65
  if (channel === undefined) {
@@ -69,7 +80,7 @@ export const sendTestWrite = defineWriteHandler({
69
80
  ctx.registry,
70
81
  event.user.tenantId,
71
82
  ctx.config,
72
- (tenantId) => fileContextForTenant(ctx, tenantId),
83
+ (tenantId) => fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
73
84
  ),
74
85
  };
75
86
  const address = (await channel.resolve?.(event.user.id, channelContext)) ?? null;
@@ -81,8 +92,8 @@ export const sendTestWrite = defineWriteHandler({
81
92
  );
82
93
  }
83
94
 
84
- const subject = `${TEST_MAIL_SUBJECT_PREFIX}${preview.subject ?? ""}`;
85
- let sent: boolean;
95
+ const subject = `${prefixSubject ? TEST_MAIL_SUBJECT_PREFIX : ""}${preview.subject ?? ""}`;
96
+ let sentStatus: string;
86
97
  try {
87
98
  const result = await channel.send(
88
99
  address,
@@ -94,13 +105,31 @@ export const sendTestWrite = defineWriteHandler({
94
105
  locale: event.payload.locale,
95
106
  },
96
107
  channelContext,
97
- { subject, html: preview.html, text: preview.text ?? "", attachments: mailAttachments },
108
+ {
109
+ subject,
110
+ html: preview.html,
111
+ text: preview.text ?? "",
112
+ ...sender,
113
+ attachments: mailAttachments,
114
+ },
98
115
  );
99
- sent = result.status === "sent";
100
- } catch {
116
+ sentStatus = result.status;
117
+ } catch (error) {
118
+ // Class and code only: the message can carry the address or a provider response body.
119
+ ctx.log?.warn("template test mail: channel threw", {
120
+ tenantId: event.user.tenantId,
121
+ errorClass: error instanceof Error ? error.name : typeof error,
122
+ errorCode: errorCodeOf(error),
123
+ });
101
124
  return testMailFailed("channel_error");
102
125
  }
103
- if (!sent) return testMailFailed("not_sent");
126
+ if (sentStatus !== "sent") {
127
+ ctx.log?.warn("template test mail: channel did not send", {
128
+ tenantId: event.user.tenantId,
129
+ status: sentStatus,
130
+ });
131
+ return testMailFailed("not_sent");
132
+ }
104
133
  return { isSuccess: true as const, data: { subject, checks: preview.checks } };
105
134
  },
106
135
  });
@@ -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