@cosmicdrift/kumiko-samples 0.364.0 → 0.365.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  33. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  34. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  35. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  36. package/packages/bundled-features/src/delivery/changes.json +12 -0
  37. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  38. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  39. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  40. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  41. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  42. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  43. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  44. package/packages/bundled-features/src/jobs/changes.json +12 -0
  45. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  46. package/packages/bundled-features/src/metrics/changes.json +6 -0
  47. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  48. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  49. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  50. package/packages/bundled-features/src/secrets/changes.json +6 -0
  51. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  52. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  53. package/packages/bundled-features/src/shared/index.ts +8 -0
  54. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  55. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  56. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  57. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  58. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  59. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  60. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  61. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  62. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  63. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  64. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  65. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  66. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  67. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  68. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  69. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  70. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  71. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  72. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  73. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  74. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  75. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  76. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  77. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  78. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  79. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  80. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  81. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  82. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  83. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  84. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  85. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  86. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  87. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  88. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  89. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  91. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  92. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  93. package/packages/bundled-features/src/tenant/changes.json +6 -0
  94. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  95. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  96. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  97. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  98. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  99. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  100. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  101. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  102. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  103. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  104. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  105. package/packages/bundled-features/src/user/constants.ts +1 -0
  106. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  107. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  108. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  109. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  112. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  113. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  114. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  115. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  116. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  117. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  118. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  119. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  120. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  121. package/samples/apps/use-all-bundled/README.md +3 -13
  122. package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
  123. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  124. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  125. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  126. package/samples/recipes/auth-mfa/README.md +2 -1
  127. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  128. package/samples/recipes/custom-handlers/README.md +4 -3
  129. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  130. package/samples/recipes/dashboard-stats/README.md +38 -0
  131. package/samples/recipes/dashboard-stats/package.json +8 -0
  132. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  133. package/samples/recipes/legal-pages/README.md +2 -2
  134. package/samples/recipes/managed-pages/README.md +2 -2
  135. package/samples/recipes/pdf-audit-export/README.md +2 -1
  136. package/samples/recipes/pdf-audit-export/package.json +1 -1
  137. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  138. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  139. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  140. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  141. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  142. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  143. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -2,7 +2,10 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  defineWriteHandler,
4
4
  type HandlerContext,
5
+ type SessionUser,
5
6
  SYSTEM_TENANT_ID,
7
+ type TenantId,
8
+ type WriteResult,
6
9
  } from "@cosmicdrift/kumiko-framework/engine";
7
10
  import {
8
11
  AccessDeniedError,
@@ -13,12 +16,16 @@ import {
13
16
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
14
17
  import * as z from "zod";
15
18
  import { requireForTenant } from "../../shared/index.js";
16
- import { CONTENT_FORMATS } from "../constants.js";
17
- import { parseLinkedResourceEntries } from "../linked-resources.js";
19
+ import { CONTENT_FORMATS, type ContentFormat } from "../constants.js";
20
+ import { type LinkedResourceEntry, parseLinkedResourceEntries } from "../linked-resources.js";
18
21
  import { TemplateResolverErrors } from "../qualified-names.js";
22
+ import {
23
+ createLinkedResourceCopies,
24
+ prepareLinkedResourceCopiesBeforeTransaction,
25
+ } from "../store-template-resource.js";
19
26
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
20
- import { toWorkingRow } from "../template-draft.js";
21
- import { attachTemplateResources, duplicateLinkedResourceFiles } from "../template-resources.js";
27
+ import { toWorkingRow, type WorkingRow } from "../template-draft.js";
28
+ import { attachTemplateResources } from "../template-resources.js";
22
29
  import { resolveTemplateVariables } from "../template-validation.js";
23
30
  import { targetExistsIssue } from "./bundle-import.js";
24
31
  import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
@@ -62,58 +69,109 @@ async function findCopySource(
62
69
  });
63
70
  }
64
71
 
65
- export const copyLocaleWrite = defineWriteHandler({
66
- name: "copy-locale",
67
- schema: z.object({
68
- slug: templateSlugSchema,
69
- kind: kindSchema,
70
- fromLocale: localeSchema,
71
- toLocale: localeSchema,
72
- overwrite: z.boolean().default(false),
73
- /** "system" copies between system-default rows; SystemAdmin only. */
74
- scope: z.enum(["tenant", "system"]).default("tenant"),
75
- }),
76
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
77
- description:
78
- "Copies the text of one locale of a template into another locale as a draft override of the caller's tenant, taking the tenant's own row or else the system default as the source; use it as the starting point for a translation. It refuses an existing target unless overwrite is set. With scope system (SystemAdmin only) both source and target are system-default rows and the target lands as a system draft; any other caller gets a 403.",
79
- handler: async (event, ctx) => {
80
- const { slug, kind, fromLocale, toLocale, overwrite, scope } = event.payload;
81
- if (scope === "system" && !event.user.roles.includes("SystemAdmin")) {
82
- return writeFailure(
83
- new AccessDeniedError({
84
- i18nKey: "errors.access.denied",
85
- details: { reason: TemplateResolverErrors.systemAdminRequired },
86
- }),
87
- );
88
- }
89
- const tenantId = scope === "system" ? SYSTEM_TENANT_ID : event.user.tenantId;
90
- const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
91
- if (refused !== null) return refused;
72
+ const copyLocaleSchema = z.object({
73
+ slug: templateSlugSchema,
74
+ kind: kindSchema,
75
+ fromLocale: localeSchema,
76
+ toLocale: localeSchema,
77
+ overwrite: z.boolean().default(false),
78
+ /** "system" copies between system-default rows; SystemAdmin only. */
79
+ scope: z.enum(["tenant", "system"]).default("tenant"),
80
+ });
92
81
 
93
- const readDb = readDbFor(ctx, scope);
94
- const storedSource = await findCopySource(ctx, {
95
- scope,
82
+ type CopyRequest = {
83
+ readonly scope: CopyScope;
84
+ readonly tenantId: TenantId;
85
+ readonly source: WorkingRow;
86
+ readonly contentFormat: ContentFormat;
87
+ readonly sourceResources: Readonly<Record<string, LinkedResourceEntry>>;
88
+ // A system source's files belong to the system tenant: the tenant gets its own copies so
89
+ // previews, exports and the tenant's override keep working independent of the system row.
90
+ readonly sourceIsForeign: boolean;
91
+ };
92
+
93
+ // Runs before the upload (reject early, store nothing) and again in the transaction on the fresh rows.
94
+ async function checkCopyRequest(
95
+ ctx: WriteCtx,
96
+ event: {
97
+ readonly user: SessionUser;
98
+ readonly payload: z.infer<typeof copyLocaleSchema>;
99
+ },
100
+ ): Promise<WriteResult<CopyRequest>> {
101
+ const { slug, kind, fromLocale, toLocale, overwrite, scope } = event.payload;
102
+ if (scope === "system" && !event.user.roles.includes("SystemAdmin")) {
103
+ return writeFailure(
104
+ new AccessDeniedError({
105
+ i18nKey: "errors.access.denied",
106
+ details: { reason: TemplateResolverErrors.systemAdminRequired },
107
+ }),
108
+ );
109
+ }
110
+ const tenantId = scope === "system" ? SYSTEM_TENANT_ID : event.user.tenantId;
111
+ const refused = refuseTenantScopeUnderSystemTenant(scope, tenantId);
112
+ if (refused !== null) return refused;
113
+
114
+ const readDb = readDbFor(ctx, scope);
115
+ const storedSource = await findCopySource(ctx, {
116
+ scope,
117
+ tenantId,
118
+ slug,
119
+ kind,
120
+ locale: fromLocale,
121
+ });
122
+ const source = storedSource && toWorkingRow(storedSource, tenantId);
123
+ const contentFormat = source?.contentFormat;
124
+ if (!source || !isOneOf(CONTENT_FORMATS, contentFormat)) {
125
+ return writeFailure(new NotFoundError("template-resource", `${slug}/${fromLocale}`));
126
+ }
127
+
128
+ if (!overwrite) {
129
+ const target = await fetchOne<TemplateResourceRow>(readDb, templateResourcesTable, {
96
130
  tenantId,
97
131
  slug,
98
132
  kind,
99
- locale: fromLocale,
133
+ locale: toLocale,
100
134
  });
101
- const source = storedSource && toWorkingRow(storedSource, tenantId);
102
- if (!source || !isOneOf(CONTENT_FORMATS, source.contentFormat)) {
103
- return writeFailure(new NotFoundError("template-resource", `${slug}/${fromLocale}`));
135
+ if (target) {
136
+ return writeFailure(new ValidationError({ fields: [targetExistsIssue(toLocale)] }));
104
137
  }
138
+ }
139
+ return {
140
+ isSuccess: true,
141
+ data: {
142
+ scope,
143
+ tenantId,
144
+ source,
145
+ contentFormat,
146
+ sourceResources: parseLinkedResourceEntries(source.linkedResources),
147
+ sourceIsForeign: source.tenantId !== tenantId,
148
+ },
149
+ };
150
+ }
105
151
 
106
- if (!overwrite) {
107
- const target = await fetchOne<TemplateResourceRow>(readDb, templateResourcesTable, {
108
- tenantId,
109
- slug,
110
- kind,
111
- locale: toLocale,
112
- });
113
- if (target) {
114
- return writeFailure(new ValidationError({ fields: [targetExistsIssue(toLocale)] }));
115
- }
116
- }
152
+ export const copyLocaleWrite = defineWriteHandler({
153
+ name: "copy-locale",
154
+ schema: copyLocaleSchema,
155
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
156
+ description:
157
+ "Copies the text of one locale of a template into another locale as a draft override of the caller's tenant, taking the tenant's own row or else the system default as the source; use it as the starting point for a translation. It refuses an existing target unless overwrite is set. With scope system (SystemAdmin only) both source and target are system-default rows and the target lands as a system draft; any other caller gets a 403.",
158
+ beforeTransaction: async (event, ctx) => {
159
+ const request = await checkCopyRequest(ctx, event);
160
+ if (!request.isSuccess) return request;
161
+ if (!request.data.sourceIsForeign) return { isSuccess: true, data: [] };
162
+ return prepareLinkedResourceCopiesBeforeTransaction({
163
+ ctx,
164
+ user: event.user,
165
+ tenantId: request.data.tenantId,
166
+ resources: request.data.sourceResources,
167
+ });
168
+ },
169
+ handler: async (event, ctx) => {
170
+ const { slug, kind, toLocale } = event.payload;
171
+ const request = await checkCopyRequest(ctx, event);
172
+ if (!request.isSuccess) return request;
173
+ const { scope, tenantId, source, contentFormat, sourceResources, sourceIsForeign } =
174
+ request.data;
117
175
 
118
176
  const variableSchema = storedRecord(source.variableSchema);
119
177
  const written = await writeTemplateRow({
@@ -128,7 +186,7 @@ export const copyLocaleWrite = defineWriteHandler({
128
186
  kind,
129
187
  locale: toLocale,
130
188
  content: source.content,
131
- contentFormat: source.contentFormat,
189
+ contentFormat,
132
190
  variableSchema,
133
191
  meta: parseStoredJson(source.meta),
134
192
  title: source.title,
@@ -136,17 +194,9 @@ export const copyLocaleWrite = defineWriteHandler({
136
194
  },
137
195
  });
138
196
  if (!written.isSuccess) return written;
139
- const sourceResources = parseLinkedResourceEntries(source.linkedResources);
140
- // A system source's files belong to the system tenant: the tenant gets its own copies so
141
- // previews, exports and the tenant's override keep working independent of the system row.
142
- const resources =
143
- source.tenantId === tenantId
144
- ? { isSuccess: true as const, data: sourceResources }
145
- : await duplicateLinkedResourceFiles(ctx, {
146
- user: event.user,
147
- tenantId,
148
- resources: sourceResources,
149
- });
197
+ const resources = sourceIsForeign
198
+ ? await createLinkedResourceCopies({ ctx, user: event.user, tenantId }, event.prepared)
199
+ : { isSuccess: true as const, data: sourceResources };
150
200
  if (!resources.isSuccess) return resources;
151
201
  const attached = await attachTemplateResources(ctx, {
152
202
  user: event.user,
@@ -24,6 +24,7 @@ export const diffToSystemQuery = defineQueryHandler({
24
24
  slug: tenantRow.slug,
25
25
  kind: tenantRow.kind,
26
26
  locale: tenantRow.locale,
27
+ status: "active",
27
28
  });
28
29
  if (!systemRow || isHiddenSystemDraft(query.user, systemRow))
29
30
  throw new NotFoundError("template-resource", tenantRow.slug);
@@ -2,7 +2,7 @@ import { defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framew
2
2
  import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { templateBundleSchema } from "../template-bundle.js";
5
- import { importTemplateBundle } from "./bundle-import.js";
5
+ import { importTemplateBundle, prepareTemplateBundleBeforeTransaction } from "./bundle-import.js";
6
6
 
7
7
  export const importBundleSystemWrite = defineWriteHandler({
8
8
  name: "import-bundle-system",
@@ -12,24 +12,23 @@ export const importBundleSystemWrite = defineWriteHandler({
12
12
  agent: { risk: "high" as const },
13
13
  description:
14
14
  "Imports a template bundle as system-default templates, active straight away and overwriting existing system rows; SystemAdmin only. Bundles with resource files need a caller in the system tenant, because the files are stored in the caller's file space.",
15
- handler: (event, ctx) => {
15
+ beforeTransaction: async (event, ctx) => {
16
16
  const { bundle } = event.payload;
17
+ // Must run before any upload.
17
18
  if (Object.keys(bundle.resources).length > 0 && event.user.tenantId !== SYSTEM_TENANT_ID) {
18
- return Promise.resolve(
19
- writeFailure(
20
- new ValidationError({
21
- fields: [
22
- {
23
- path: "bundle.resources",
24
- code: "system_resources_need_system_tenant",
25
- i18nKey: "templateResolver.errors.template.systemResourcesNeedSystemTenant",
26
- },
27
- ],
28
- }),
29
- ),
19
+ return writeFailure(
20
+ new ValidationError({
21
+ fields: [
22
+ {
23
+ path: "bundle.resources",
24
+ code: "system_resources_need_system_tenant",
25
+ i18nKey: "templateResolver.errors.template.systemResourcesNeedSystemTenant",
26
+ },
27
+ ],
28
+ }),
30
29
  );
31
30
  }
32
- return importTemplateBundle({
31
+ return prepareTemplateBundleBeforeTransaction({
33
32
  ctx,
34
33
  user: event.user,
35
34
  tenantId: SYSTEM_TENANT_ID,
@@ -39,4 +38,15 @@ export const importBundleSystemWrite = defineWriteHandler({
39
38
  overwrite: true,
40
39
  });
41
40
  },
41
+ handler: (event, ctx) =>
42
+ importTemplateBundle({
43
+ ctx,
44
+ user: event.user,
45
+ tenantId: SYSTEM_TENANT_ID,
46
+ scope: "system",
47
+ status: "active",
48
+ bundle: event.payload.bundle,
49
+ overwrite: true,
50
+ prepared: event.prepared,
51
+ }),
42
52
  });
@@ -1,7 +1,7 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import { templateBundleSchema } from "../template-bundle.js";
4
- import { importTemplateBundle } from "./bundle-import.js";
4
+ import { importTemplateBundle, prepareTemplateBundleBeforeTransaction } from "./bundle-import.js";
5
5
 
6
6
  export const importBundleWrite = defineWriteHandler({
7
7
  name: "import-bundle",
@@ -9,6 +9,16 @@ export const importBundleWrite = defineWriteHandler({
9
9
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
10
10
  description:
11
11
  "Imports a template bundle (all locales plus resource files, as produced by export-bundle) into the caller's tenant as drafts; it refuses a locale that already has an override unless overwrite is set, and publish makes the drafts live.",
12
+ beforeTransaction: (event, ctx) =>
13
+ prepareTemplateBundleBeforeTransaction({
14
+ ctx,
15
+ user: event.user,
16
+ tenantId: event.user.tenantId,
17
+ scope: "tenant",
18
+ status: "draft",
19
+ bundle: event.payload.bundle,
20
+ overwrite: event.payload.overwrite,
21
+ }),
12
22
  handler: (event, ctx) =>
13
23
  importTemplateBundle({
14
24
  ctx,
@@ -18,5 +28,6 @@ export const importBundleWrite = defineWriteHandler({
18
28
  status: "draft",
19
29
  bundle: event.payload.bundle,
20
30
  overwrite: event.payload.overwrite,
31
+ prepared: event.prepared,
21
32
  }),
22
33
  });
@@ -52,6 +52,8 @@ export type OverviewResponse = {
52
52
  readonly rows: readonly OverviewRow[];
53
53
  readonly nextCursor: null;
54
54
  readonly total: number;
55
+ /** True when the tenant has more template rows than the overview reads; rows and total are then incomplete. */
56
+ readonly truncated: boolean;
55
57
  };
56
58
 
57
59
  const SORT_FIELDS = ["title", "slug", "kind", "origin", "status", "modifiedAt"] as const;
@@ -285,12 +287,19 @@ export const overviewQuery = defineQueryHandler({
285
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.",
286
288
  handler: async (query, ctx) => {
287
289
  const payload = query.payload;
288
- const rows = await selectMany<TemplateResourceRow>(
290
+ const fetchedRows = await selectMany<TemplateResourceRow>(
289
291
  ctx.db,
290
292
  templateResourcesTable,
291
293
  { kind: { in: [...OVERVIEW_KINDS] } },
292
- { limit: OVERVIEW_ROW_LIMIT },
294
+ { orderBy: { col: "id", direction: "asc" }, limit: OVERVIEW_ROW_LIMIT + 1 },
293
295
  );
296
+ const truncated = fetchedRows.length > OVERVIEW_ROW_LIMIT;
297
+ const rows = truncated ? fetchedRows.slice(0, OVERVIEW_ROW_LIMIT) : fetchedRows;
298
+ if (truncated) {
299
+ ctx.log?.warn("template overview: row limit reached, result is incomplete", {
300
+ limit: OVERVIEW_ROW_LIMIT,
301
+ });
302
+ }
294
303
 
295
304
  const visibleRows = rows.filter((row) => !isHiddenSystemDraft(query.user, row));
296
305
  const { drafts, layoutSlugByDocument } = buildDrafts(
@@ -318,6 +327,7 @@ export const overviewQuery = defineQueryHandler({
318
327
  rows: filtered.slice(offset, offset + payload.limit),
319
328
  nextCursor: null,
320
329
  total: filtered.length,
330
+ truncated,
321
331
  } satisfies OverviewResponse;
322
332
  },
323
333
  });
@@ -1,4 +1,8 @@
1
- import { defineWriteHandler, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ type BeforeTransactionResult,
3
+ defineWriteHandler,
4
+ type WriteResult,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import {
3
7
  NotFoundError,
4
8
  UnprocessableError,
@@ -13,11 +17,14 @@ import {
13
17
  import { attachmentFileNameSchema } from "../../shared/index.js";
14
18
  import { TemplateResolverErrors } from "../qualified-names.js";
15
19
  import {
20
+ type PreparedTemplateFile,
21
+ prepareTemplateAttachmentBeforeTransaction,
22
+ prepareTemplateResourceBeforeTransaction,
16
23
  readTemplateResourceMaxBytes,
17
24
  type StoredTemplateAttachment,
18
25
  type StoredTemplateResource,
19
- storeTemplateAttachment,
20
- storeTemplateResource,
26
+ storePreparedTemplateAttachment,
27
+ storePreparedTemplateResource,
21
28
  } from "../store-template-resource.js";
22
29
  import { RESOURCE_PURPOSES, resourceKeySchema, resourceTargetTenantId } from "./resource-shared.js";
23
30
 
@@ -66,10 +73,7 @@ export const resourceUploadWrite = defineWriteHandler({
66
73
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
67
74
  description:
68
75
  "Turns a file uploaded via POST /api/files (multipart, no entityType) into a template resource. With purpose image (default) the upload is checked against the resource-max-bytes cap, re-encoded to a supported image format and stored as a new file; with templateId and key it is linked into that template under the key, without them only the file is created. With purpose attachment, templateId, key and fileName are required: a PDF, PNG or JPEG is stored unchanged and linked into a mail template as a file attached to every mail sent from it, at most 10 per template. A cap above the files route's maxUploadSize has no effect.",
69
- handler: async (
70
- event,
71
- ctx,
72
- ): Promise<WriteResult<StoredTemplateResource | StoredTemplateAttachment>> => {
76
+ beforeTransaction: async (event, ctx): Promise<BeforeTransactionResult<PreparedTemplateFile>> => {
73
77
  const { templateId, key, sourceFileRefId, purpose, fileName } = event.payload;
74
78
  const attachmentTarget =
75
79
  purpose === "attachment" ? attachmentTargetOf({ templateId, key, fileName }) : null;
@@ -113,7 +117,7 @@ export const resourceUploadWrite = defineWriteHandler({
113
117
  // The source upload stays as an unclaimed file: bundled-features cannot reach the fileRef executor to delete it.
114
118
  const tenantId = resourceTargetTenantId(event.payload.tenantIdOverride, event.user.tenantId);
115
119
  if (attachmentTarget !== null) {
116
- return storeTemplateAttachment({
120
+ return prepareTemplateAttachmentBeforeTransaction({
117
121
  ctx,
118
122
  user: event.user,
119
123
  tenantId,
@@ -122,7 +126,7 @@ export const resourceUploadWrite = defineWriteHandler({
122
126
  ...attachmentTarget,
123
127
  });
124
128
  }
125
- return storeTemplateResource({
129
+ return prepareTemplateResourceBeforeTransaction({
126
130
  ctx,
127
131
  user: event.user,
128
132
  tenantId,
@@ -132,4 +136,39 @@ export const resourceUploadWrite = defineWriteHandler({
132
136
  ...(key !== undefined && { key }),
133
137
  });
134
138
  },
139
+ handler: async (
140
+ event,
141
+ ctx,
142
+ ): Promise<WriteResult<StoredTemplateResource | StoredTemplateAttachment>> => {
143
+ const { templateId, key } = event.payload;
144
+ const tenantId = resourceTargetTenantId(event.payload.tenantIdOverride, event.user.tenantId);
145
+ const { prepared } = event;
146
+ if (prepared.kind === "attachment") {
147
+ // The phase accepts attachments only with a complete target, so these are set.
148
+ if (templateId === undefined || key === undefined) {
149
+ return writeFailure(
150
+ new UnprocessableError(TemplateResolverErrors.attachmentInvalid, {
151
+ i18nKey: "templateResolver.errors.attachment.invalid",
152
+ details: { code: "target_required" },
153
+ }),
154
+ );
155
+ }
156
+ return storePreparedTemplateAttachment({
157
+ ctx,
158
+ user: event.user,
159
+ tenantId,
160
+ prepared,
161
+ templateId,
162
+ key,
163
+ });
164
+ }
165
+ return storePreparedTemplateResource({
166
+ ctx,
167
+ user: event.user,
168
+ tenantId,
169
+ prepared,
170
+ ...(templateId !== undefined && { templateId }),
171
+ ...(key !== undefined && { key }),
172
+ });
173
+ },
135
174
  });
@@ -10,6 +10,7 @@ import type { ContentFormat, UpsertKind } from "../constants.js";
10
10
  import type { TemplateWriteScope } from "../layout-regions.js";
11
11
  import { imageResourceKeysOf } from "../linked-resources.js";
12
12
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
13
+ import { isSystemAdmin } from "../template-draft.js";
13
14
  import { resolveTemplateVariables, systemPlaceholdersFor } from "../template-validation.js";
14
15
  import { contentFormatSchema, kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
15
16
  import { evaluateTemplateWrite } from "./template-gates.js";
@@ -53,7 +54,9 @@ export async function validateTemplateDraft(
53
54
  payload: TemplateDraftInput,
54
55
  ) {
55
56
  const variables = resolveTemplateVariables(ctx.registry, payload.slug, payload.variableSchema);
56
- const { slug, kind, scope, locale } = payload;
57
+ const { slug, kind, locale } = payload;
58
+ // The dry run must not promise what the caller's write handlers would refuse.
59
+ const scope: TemplateWriteScope = isSystemAdmin(user) ? payload.scope : "tenant";
57
60
  const known = [
58
61
  ...(await knownResourceKeys(ctx.db, {
59
62
  tenantId: SYSTEM_TENANT_ID,
@@ -16,6 +16,7 @@ export {
16
16
  CONTENT_FORMATS,
17
17
  type ContentFormat,
18
18
  DEFAULT_MAIL_LAYOUT_SLUG,
19
+ DEFAULT_PDF_LAYOUT_SLUG,
19
20
  DOCUMENT_HTML_KINDS,
20
21
  type DocumentHtmlKind,
21
22
  FALLBACK_LOCALE,
@@ -110,9 +111,11 @@ export {
110
111
  } from "./qualified-names.js";
111
112
  export { mapRecordToVariables } from "./record-mapping.js";
112
113
  export {
114
+ type PreparedTemplateImage,
115
+ prepareTemplateResourceBeforeTransaction,
113
116
  readTemplateResourceMaxBytes,
114
117
  type StoredTemplateResource,
115
- storeTemplateResource,
118
+ storePreparedTemplateResource,
116
119
  } from "./store-template-resource.js";
117
120
  export {
118
121
  type TemplateResourceRow,
@@ -124,9 +127,11 @@ export {
124
127
  isMailTemplateMeta,
125
128
  type LayoutTemplateMeta,
126
129
  MAIL_CATEGORIES,
130
+ MAIL_TONES,
127
131
  type MailCategory,
128
132
  type MailLayoutMeta,
129
133
  type MailTemplateMeta,
134
+ type MailTone,
130
135
  type PdfTemplateMeta,
131
136
  parseTemplateMeta,
132
137
  pdfAttachmentFileNamePatternSchema,
@@ -15,26 +15,42 @@ const FONT_FAMILIES = {
15
15
  serif: "Georgia, 'Times New Roman', serif",
16
16
  } as const;
17
17
 
18
- const BUTTON_COLOR = "#1f5468";
18
+ const DEFAULT_BUTTON_COLOR = "#1f5468";
19
19
  const BUTTON_BASE = "display:inline-block;padding:10px 20px;font-weight:600;text-decoration:none";
20
20
 
21
- const BUTTON_STYLES = {
22
- "filled-rounded": `${BUTTON_BASE};border-radius:6px;background-color:${BUTTON_COLOR};color:#ffffff`,
23
- "filled-square": `${BUTTON_BASE};background-color:${BUTTON_COLOR};color:#ffffff`,
24
- outline: `${BUTTON_BASE};border:2px solid ${BUTTON_COLOR};border-radius:6px;color:${BUTTON_COLOR}`,
25
- } as const;
21
+ type ButtonStyle = NonNullable<MailLayoutMeta["buttonStyle"]>;
22
+
23
+ function buttonStyleCss(style: ButtonStyle, color: string): string {
24
+ switch (style) {
25
+ case "filled-rounded":
26
+ return `${BUTTON_BASE};border-radius:6px;background-color:${color};color:#ffffff`;
27
+ case "filled-square":
28
+ return `${BUTTON_BASE};background-color:${color};color:#ffffff`;
29
+ case "outline":
30
+ return `${BUTTON_BASE};border:2px solid ${color};border-radius:6px;color:${color}`;
31
+ }
32
+ }
26
33
 
27
34
  export function isHexColor(value: string): boolean {
28
35
  return HEX_COLOR_PATTERN.test(value);
29
36
  }
30
37
 
31
- export function mailLayoutAppearance(meta: MailLayoutMeta | unknown): MailLayoutAppearance {
38
+ // The branding accent colors the buttons only where the layout opted into the branding accent line.
39
+ export function mailLayoutAppearance(
40
+ meta: MailLayoutMeta | unknown,
41
+ brandingAccentColor?: string,
42
+ ): MailLayoutAppearance {
32
43
  const parsed = readMailLayoutMeta(meta);
44
+ const accentLine = (parsed.accentLine ?? "none") === "branding";
45
+ const buttonColor =
46
+ accentLine && brandingAccentColor !== undefined && isHexColor(brandingAccentColor)
47
+ ? brandingAccentColor
48
+ : DEFAULT_BUTTON_COLOR;
33
49
  return {
34
50
  width: parsed.width ?? 600,
35
51
  fontFamily: FONT_FAMILIES[parsed.font ?? "sans"],
36
52
  outerBackground: parsed.outerBackground ?? "#f5f5f5",
37
- accentLine: (parsed.accentLine ?? "none") === "branding",
38
- buttonStyle: BUTTON_STYLES[parsed.buttonStyle ?? "filled-rounded"],
53
+ accentLine,
54
+ buttonStyle: buttonStyleCss(parsed.buttonStyle ?? "filled-rounded", buttonColor),
39
55
  };
40
56
  }
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+ // No imports: web/ re-exports these, and template-meta.ts pulls in zod and the framework engine.
3
+ export const MAIL_TONES = ["accent", "info", "success", "warning", "danger", "neutral"] as const;
4
+ export type MailTone = (typeof MAIL_TONES)[number];
@@ -1,27 +1,20 @@
1
- import { assertNotSvg, SHARP_INPUT_OPTIONS } from "../shared/index.js";
1
+ import {
2
+ assertNotSvg,
3
+ isResourceImageFormat,
4
+ RESOURCE_IMAGE_FORMATS,
5
+ type ResourceImageContentType,
6
+ SHARP_INPUT_OPTIONS,
7
+ } from "../shared/index.js";
2
8
 
3
9
  const MAX_RESOURCE_WIDTH = 1200;
4
10
 
5
- const CONTENT_TYPE_BY_FORMAT = {
6
- png: "image/png",
7
- jpeg: "image/jpeg",
8
- gif: "image/gif",
9
- webp: "image/webp",
10
- } as const;
11
-
12
- type ResourceImageFormat = keyof typeof CONTENT_TYPE_BY_FORMAT;
13
-
14
11
  export type NormalizedResourceImage = {
15
12
  readonly data: Uint8Array;
16
- readonly contentType: (typeof CONTENT_TYPE_BY_FORMAT)[ResourceImageFormat];
13
+ readonly contentType: ResourceImageContentType;
17
14
  readonly width: number;
18
15
  readonly height: number;
19
16
  };
20
17
 
21
- function isResourceImageFormat(format: string | undefined): format is ResourceImageFormat {
22
- return format !== undefined && Object.hasOwn(CONTENT_TYPE_BY_FORMAT, format);
23
- }
24
-
25
18
  // Re-encodes an uploaded image: the stored content type comes from the decoded format, never
26
19
  // from the client, and re-encoding drops metadata and anything appended to the file.
27
20
  // sharp is imported lazily because every app loads template-resolver, few of them need an image decoder.
@@ -40,7 +33,7 @@ export async function normalizeResourceImage(data: Uint8Array): Promise<Normaliz
40
33
  const { data: output, info } = await pipeline.toBuffer({ resolveWithObject: true });
41
34
  return {
42
35
  data: new Uint8Array(output),
43
- contentType: CONTENT_TYPE_BY_FORMAT[format],
36
+ contentType: RESOURCE_IMAGE_FORMATS[format].contentType,
44
37
  width: info.width,
45
38
  height: info.height,
46
39
  };
@@ -155,6 +155,8 @@ export async function seedSystemTemplate(
155
155
  current?.title === rowFields.title &&
156
156
  current?.variableSchema === rowFields.variableSchema &&
157
157
  current?.meta === rowFields.meta &&
158
+ current?.status === rowFields.status &&
159
+ current?.parentTemplateId === rowFields.parentTemplateId &&
158
160
  (linkedResources === undefined || current?.linkedResources === linkedResources)
159
161
  ) {
160
162
  return { id: String(row.id) };