@cosmicdrift/kumiko-samples 0.364.0 → 0.365.1

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 (148) 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/changes.json +7 -0
  33. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  34. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  35. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  36. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  37. package/packages/bundled-features/src/delivery/changes.json +12 -0
  38. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  39. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  40. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  41. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  42. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  43. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  44. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  45. package/packages/bundled-features/src/jobs/changes.json +12 -0
  46. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  47. package/packages/bundled-features/src/metrics/changes.json +6 -0
  48. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  49. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  50. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  51. package/packages/bundled-features/src/secrets/changes.json +6 -0
  52. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  53. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  54. package/packages/bundled-features/src/shared/index.ts +8 -0
  55. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  56. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  57. package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
  58. package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
  59. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
  60. package/packages/bundled-features/src/step-dispatcher/feature.ts +65 -118
  61. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
  62. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  63. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  64. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  65. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  66. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  67. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  68. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  69. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  70. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  71. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  72. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  73. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  75. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  76. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  78. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  79. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  80. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  81. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  82. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  83. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  84. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  85. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  86. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  87. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  88. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  89. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  90. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  91. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  92. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  93. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  94. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  95. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  96. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  97. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  98. package/packages/bundled-features/src/tenant/changes.json +6 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  100. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  101. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  102. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  104. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +9 -3
  105. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  106. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  107. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  108. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  109. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  110. package/packages/bundled-features/src/user/constants.ts +1 -0
  111. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  112. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  113. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  114. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  115. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  116. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  117. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  118. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  119. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  120. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  121. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  122. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  123. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  124. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  125. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  126. package/samples/apps/use-all-bundled/README.md +3 -13
  127. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  128. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  129. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  130. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  131. package/samples/recipes/auth-mfa/README.md +2 -1
  132. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  133. package/samples/recipes/custom-handlers/README.md +4 -3
  134. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  135. package/samples/recipes/dashboard-stats/README.md +38 -0
  136. package/samples/recipes/dashboard-stats/package.json +8 -0
  137. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  138. package/samples/recipes/legal-pages/README.md +2 -2
  139. package/samples/recipes/managed-pages/README.md +2 -2
  140. package/samples/recipes/pdf-audit-export/README.md +2 -1
  141. package/samples/recipes/pdf-audit-export/package.json +1 -1
  142. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  143. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  144. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  145. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  146. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  147. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  148. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,5 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type {
3
+ BeforeTransactionResult,
3
4
  HandlerContext,
4
5
  SessionUser,
5
6
  TenantId,
@@ -16,9 +17,13 @@ import type { LinkedResourceEntry } from "../linked-resources.js";
16
17
  import {
17
18
  attachmentLimitFailure,
18
19
  attachmentNeedsMailTemplate,
20
+ createPreparedBundleResourceEntry,
21
+ type PreparedTemplateFile,
22
+ prepareTemplateAttachmentBeforeTransaction,
23
+ prepareTemplateResourceBeforeTransaction,
24
+ type Release,
19
25
  readTemplateResourceMaxBytes,
20
- storeBundleAttachment,
21
- storeTemplateResource,
26
+ releaseAll,
22
27
  } from "../store-template-resource.js";
23
28
  import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
24
29
  import type { TemplateBundle } from "../template-bundle.js";
@@ -92,64 +97,101 @@ async function collectBundleIssues(
92
97
  return issues;
93
98
  }
94
99
 
95
- async function storeBundleResources(
96
- args: BundleImport,
97
- ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
98
- const linked: Record<string, LinkedResourceEntry> = {};
99
- const pdfLinks = Object.entries(args.bundle.pdfAttachments);
100
+ export type PreparedBundleResources = ReadonlyMap<string, PreparedTemplateFile>;
101
+
102
+ function checkBundleAttachments(bundle: TemplateBundle): WriteResult<null> {
100
103
  const attachmentCount =
101
- Object.values(args.bundle.resources).filter((resource) => resource.purpose === "attachment")
102
- .length + pdfLinks.length;
103
- if (attachmentCount > 0 && args.bundle.kind !== "mail") return attachmentNeedsMailTemplate();
104
+ Object.values(bundle.resources).filter((resource) => resource.purpose === "attachment").length +
105
+ Object.keys(bundle.pdfAttachments).length;
106
+ if (attachmentCount > 0 && bundle.kind !== "mail") return attachmentNeedsMailTemplate();
104
107
  if (attachmentCount > MAX_FILE_ATTACHMENTS_PER_TEMPLATE) return attachmentLimitFailure();
105
- // The document template is not looked up: a bundle may carry it later than the mail.
106
- for (const [key, link] of pdfLinks) linked[key] = { kind: "rendered-pdf", ...link };
108
+ return { isSuccess: true, data: null };
109
+ }
110
+
111
+ // beforeTransaction half: validates the bundle and uploads every resource file to the target
112
+ // tenant's storage. A failed or throwing upload releases the ones before it.
113
+ export async function prepareTemplateBundleBeforeTransaction(
114
+ args: BundleImport & { readonly status: BundleImportStatus },
115
+ ): Promise<BeforeTransactionResult<PreparedBundleResources>> {
116
+ const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
117
+ if (refused !== null) return refused;
118
+ const issues = await collectBundleIssues(args, args.status);
119
+ if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
120
+ const attachmentCheck = checkBundleAttachments(args.bundle);
121
+ if (!attachmentCheck.isSuccess) return attachmentCheck;
122
+
107
123
  const maxBytes = await readTemplateResourceMaxBytes(args.ctx.config, args.ctx.registry);
108
- // A bundle is untrusted input like an upload: the same re-encoding decides the stored
109
- // content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
110
- for (const [key, resource] of Object.entries(args.bundle.resources)) {
111
- const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
112
- if (resource.purpose === "attachment") {
113
- const attachment = await storeBundleAttachment({
124
+ const prepared = new Map<string, PreparedTemplateFile>();
125
+ const releases: Release[] = [];
126
+ try {
127
+ // A bundle is untrusted input like an upload: the same re-encoding decides the stored
128
+ // content type, so a declared image/png can't smuggle SVG or HTML into a mail or PDF.
129
+ for (const [key, resource] of Object.entries(args.bundle.resources)) {
130
+ const bytes = Uint8Array.from(Buffer.from(resource.base64, "base64"));
131
+ const common = {
114
132
  ctx: args.ctx,
115
133
  user: args.user,
116
134
  tenantId: args.tenantId,
117
135
  bytes,
118
136
  maxBytes,
119
137
  key,
120
- fileName: resource.fileName,
121
- });
122
- if (!attachment.isSuccess) return attachment;
123
- linked[key] = attachment.data;
124
- continue;
138
+ };
139
+ const result =
140
+ resource.purpose === "attachment"
141
+ ? await prepareTemplateAttachmentBeforeTransaction({
142
+ ...common,
143
+ fileName: resource.fileName,
144
+ })
145
+ : await prepareTemplateResourceBeforeTransaction(common);
146
+ if (!result.isSuccess) {
147
+ await releaseAll(releases);
148
+ return result;
149
+ }
150
+ if (result.release) releases.push(result.release);
151
+ prepared.set(key, result.data);
125
152
  }
126
- const stored = await storeTemplateResource({
127
- ctx: args.ctx,
128
- user: args.user,
129
- tenantId: args.tenantId,
130
- bytes,
131
- maxBytes,
132
- key,
133
- });
134
- if (!stored.isSuccess) return stored;
135
- linked[key] = stored.data.fileRefId;
153
+ } catch (cause) {
154
+ await releaseAll(releases);
155
+ throw cause;
156
+ }
157
+ return { isSuccess: true, data: prepared, release: () => releaseAll(releases) };
158
+ }
159
+
160
+ async function linkPreparedBundleResources(
161
+ args: BundleImport,
162
+ prepared: PreparedBundleResources,
163
+ ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
164
+ const linked: Record<string, LinkedResourceEntry> = {};
165
+ // The document template is not looked up: a bundle may carry it later than the mail.
166
+ for (const [key, link] of Object.entries(args.bundle.pdfAttachments)) {
167
+ linked[key] = { kind: "rendered-pdf", ...link };
168
+ }
169
+ for (const [key, file] of prepared) {
170
+ const entry = await createPreparedBundleResourceEntry(
171
+ { ctx: args.ctx, user: args.user, tenantId: args.tenantId },
172
+ file,
173
+ );
174
+ if (!entry.isSuccess) return entry;
175
+ linked[key] = entry.data;
136
176
  }
137
177
  return { isSuccess: true, data: linked };
138
178
  }
139
179
 
140
- // Validates every locale first, stores the resource files, then writes one row
141
- // per locale, so a bad locale never leaves files or half a bundle behind. An image
142
- // that fails re-encoding stops the import before any row is written; files stored
143
- // for the resources before it stay as unlinked FileRefs, like a replaced upload.
180
+ // Validates every locale first, so a bad locale is refused before anything is written.
181
+ // The resource files are uploaded in the beforeTransaction phase; a rolled-back transaction
182
+ // releases them there. A crash between the upload and the commit leaves the binaries unreferenced.
144
183
  export async function importTemplateBundle(
145
- args: BundleImport & { readonly status: BundleImportStatus },
184
+ args: BundleImport & {
185
+ readonly status: BundleImportStatus;
186
+ readonly prepared: PreparedBundleResources;
187
+ },
146
188
  ): Promise<WriteResult<{ ids: Record<string, string> }>> {
147
189
  const refused = refuseTenantScopeUnderSystemTenant(args.scope, args.tenantId);
148
190
  if (refused !== null) return refused;
149
191
  const issues = await collectBundleIssues(args, args.status);
150
192
  if (issues.length > 0) return writeFailure(new ValidationError({ fields: [...issues] }));
151
193
 
152
- const linked = await storeBundleResources(args);
194
+ const linked = await linkPreparedBundleResources(args, args.prepared);
153
195
  if (!linked.isSuccess) return linked;
154
196
 
155
197
  const variableSchema = args.bundle.variableSchema ?? {};
@@ -7,7 +7,7 @@ import {
7
7
  import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import * as z from "zod";
9
9
  import { requireForTenant } from "../../shared/index.js";
10
- import { COLLECTION_REFUSED_KINDS } from "../constants.js";
10
+ import { isCollectionRefusedKind } from "../constants.js";
11
11
  import { documentViolationsToFieldIssues, sanitizeTemplateBody } from "../document-sanitizer.js";
12
12
  import {
13
13
  type CollectionEntryRow,
@@ -43,12 +43,13 @@ export function makeCollectionSetWrite(collection: ContentCollectionDefinition)
43
43
  ? `Creates or overwrites the caller's own "${collection.id}" entry at a slug and locale, live on save with no draft stage; it always writes the acting user's entry, never another user's.`
44
44
  : `Creates or overwrites the tenant-wide "${collection.id}" entry at a slug and locale, live on save with no draft stage; the change is visible to everyone who may reach this shared collection.`,
45
45
  handler: async (event, ctx) => {
46
- if (COLLECTION_REFUSED_KINDS.includes(collection.kind)) {
46
+ // Defense in depth: createTemplateResolverFeature already refuses these kinds at mount.
47
+ if (isCollectionRefusedKind(collection.kind)) {
47
48
  return writeFailure(
48
49
  new ValidationError({
49
50
  fields: [
50
51
  {
51
- path: "kind",
52
+ path: "",
52
53
  code: "kind_not_allowed",
53
54
  i18nKey: "templateResolver.errors.collection.kindNotAllowed",
54
55
  params: { detail: collection.kind },
@@ -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
  });