@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
@@ -1,5 +1,6 @@
1
1
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type {
3
+ BeforeTransactionResult,
3
4
  ConfigAccessor,
4
5
  HandlerContext,
5
6
  Registry,
@@ -12,11 +13,15 @@ import {
12
13
  UnprocessableError,
13
14
  writeFailure,
14
15
  } from "@cosmicdrift/kumiko-framework/errors";
15
- import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
16
+ import {
17
+ type CreateFileRefInput,
18
+ createFileRefFromUploadedBytes,
19
+ type UploadedFileBytes,
20
+ uploadFileBytesBeforeTransaction,
21
+ } from "@cosmicdrift/kumiko-framework/files";
16
22
  import { fileContextForTenant } from "../file-foundation/index.js";
17
- import type { FileAttachmentRef } from "../renderer-foundation/index.js";
18
- import { requireForTenant } from "../shared/index.js";
19
- import { SYSTEM_TENANT_ID } from "./constants.js";
23
+ import { extensionForResourceContentType, requireForTenant } from "../shared/index.js";
24
+ import { ACTIVE_TEMPLATE_STATUS, SYSTEM_TENANT_ID } from "./constants.js";
20
25
  import {
21
26
  DEFAULT_RESOURCE_MAX_BYTES,
22
27
  MAX_FILE_ATTACHMENTS_PER_TEMPLATE,
@@ -32,13 +37,7 @@ import {
32
37
  import { TemplateResolverErrors } from "./qualified-names.js";
33
38
  import { normalizeResourceImage } from "./resource-image.js";
34
39
  import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
35
-
36
- const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
37
- "image/png": "png",
38
- "image/jpeg": "jpg",
39
- "image/gif": "gif",
40
- "image/webp": "webp",
41
- };
40
+ import { readTemplateResource } from "./template-resources.js";
42
41
 
43
42
  export async function readTemplateResourceMaxBytes(
44
43
  config: ConfigAccessor | undefined,
@@ -60,82 +59,311 @@ export type StoredTemplateResource = {
60
59
  readonly size: number;
61
60
  };
62
61
 
63
- // Normalizes the image, stores it as a file ref of the target tenant and, with templateId + key,
64
- // links it into that tenant's template. The caller is responsible for authorization; the function trusts tenantId.
65
- export async function storeTemplateResource(args: {
62
+ export type PreparedTemplateImage = {
63
+ readonly kind: "image";
64
+ readonly contentType: string;
65
+ readonly width: number;
66
+ readonly height: number;
67
+ readonly size: number;
68
+ readonly upload: UploadedFileBytes;
69
+ };
70
+
71
+ export type PreparedTemplateAttachment = {
72
+ readonly kind: "attachment";
73
+ readonly contentType: string;
74
+ readonly size: number;
75
+ readonly fileName: string;
76
+ readonly upload: UploadedFileBytes;
77
+ };
78
+
79
+ export type PreparedTemplateFile = PreparedTemplateImage | PreparedTemplateAttachment;
80
+
81
+ function keyInUse(): WriteResult<never> {
82
+ return writeFailure(
83
+ new UnprocessableError(TemplateResolverErrors.attachmentInvalid, {
84
+ i18nKey: "templateResolver.errors.attachment.invalid",
85
+ details: { code: "key_in_use" },
86
+ }),
87
+ );
88
+ }
89
+
90
+ function isAttachmentKey(links: Readonly<Record<string, LinkedResourceEntry>>, key: string) {
91
+ const occupant = links[key];
92
+ return occupant !== undefined && typeof occupant !== "string";
93
+ }
94
+
95
+ // The upload goes to the target tenant's storage and the release checks that tenant's fileRef rows,
96
+ // so both receive the target tenant's db and identity.
97
+ function uploadToTenant(
98
+ args: {
99
+ readonly ctx: HandlerContext;
100
+ readonly user: SessionUser;
101
+ readonly tenantId: TenantId;
102
+ },
103
+ input: CreateFileRefInput,
104
+ ): Promise<BeforeTransactionResult<UploadedFileBytes>> {
105
+ const { ctx, user, tenantId } = args;
106
+ return uploadFileBytesBeforeTransaction(
107
+ {
108
+ db: requireForTenant(ctx, tenantId).db,
109
+ files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
110
+ registry: ctx.registry,
111
+ user: { ...user, tenantId },
112
+ },
113
+ input,
114
+ );
115
+ }
116
+
117
+ type TenantScope = {
66
118
  readonly ctx: HandlerContext;
67
119
  readonly user: SessionUser;
68
120
  readonly tenantId: TenantId;
69
- readonly bytes: Uint8Array;
70
- readonly maxBytes: number;
71
- readonly templateId?: string;
72
- readonly key?: string;
73
- }): Promise<WriteResult<StoredTemplateResource>> {
74
- const { ctx, user, tenantId, bytes, maxBytes, templateId, key } = args;
121
+ };
122
+
123
+ function createFileRefFromPrepared(scope: TenantScope, upload: UploadedFileBytes) {
124
+ const { ctx, user, tenantId } = scope;
125
+ return createFileRefFromUploadedBytes(
126
+ { db: requireForTenant(ctx, tenantId).db, user: { ...user, tenantId } },
127
+ upload,
128
+ );
129
+ }
130
+
131
+ async function linkImage(args: {
132
+ readonly ctx: HandlerContext;
133
+ readonly user: SessionUser;
134
+ readonly tenantId: TenantId;
135
+ readonly row: TemplateResourceRow;
136
+ readonly key: string;
137
+ readonly links: Readonly<Record<string, LinkedResourceEntry>>;
138
+ readonly fileRefId: string;
139
+ }): Promise<WriteResult<null>> {
140
+ const { ctx, user, tenantId, row, key, links, fileRefId } = args;
141
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
142
+ // The replaced FileRef stays: copy-locale and tenant overrides can share ids and
143
+ // there is no reference count to tell when the last user is gone.
144
+ const updated = await executor.update(
145
+ {
146
+ id: row.id,
147
+ version: row.version,
148
+ changes: { linkedResources: JSON.stringify({ ...links, [key]: fileRefId }) },
149
+ },
150
+ user,
151
+ db,
152
+ { streamTenantId },
153
+ );
154
+ return updated.isSuccess ? { isSuccess: true, data: null } : updated;
155
+ }
156
+
157
+ type NormalizedUpload = {
158
+ readonly image: Awaited<ReturnType<typeof normalizeResourceImage>>;
159
+ };
160
+
161
+ async function normalizeUpload(
162
+ bytes: Uint8Array,
163
+ maxBytes: number,
164
+ ): Promise<{ readonly failure: WriteResult<never> } | NormalizedUpload> {
75
165
  if (bytes.length > maxBytes) {
76
- return writeFailure(
77
- new UnprocessableError(TemplateResolverErrors.resourceTooLarge, {
78
- i18nKey: "templateResolver.errors.resource.tooLarge",
79
- details: { maxBytes, size: bytes.length },
80
- }),
81
- );
166
+ return {
167
+ failure: writeFailure(
168
+ new UnprocessableError(TemplateResolverErrors.resourceTooLarge, {
169
+ i18nKey: "templateResolver.errors.resource.tooLarge",
170
+ details: { maxBytes, size: bytes.length },
171
+ }),
172
+ ),
173
+ };
82
174
  }
83
- let image: Awaited<ReturnType<typeof normalizeResourceImage>>;
84
175
  try {
85
- image = await normalizeResourceImage(Buffer.from(bytes));
176
+ return { image: await normalizeResourceImage(Buffer.from(bytes)) };
86
177
  } catch (cause) {
87
- return writeFailure(
88
- new UnprocessableError(TemplateResolverErrors.resourceUnsupportedImage, {
89
- i18nKey: "templateResolver.errors.resource.unsupportedImage",
90
- cause: cause instanceof Error ? cause : undefined,
91
- }),
92
- );
178
+ return {
179
+ failure: writeFailure(
180
+ new UnprocessableError(TemplateResolverErrors.resourceUnsupportedImage, {
181
+ i18nKey: "templateResolver.errors.resource.unsupportedImage",
182
+ cause: cause instanceof Error ? cause : undefined,
183
+ }),
184
+ ),
185
+ };
93
186
  }
187
+ }
94
188
 
95
- const { db, streamTenantId } = requireForTenant(ctx, tenantId);
189
+ type ImageTarget = {
190
+ readonly row: TemplateResourceRow | undefined;
191
+ readonly links: Readonly<Record<string, LinkedResourceEntry>>;
192
+ };
193
+
194
+ // Runs before the upload (reject early, store nothing) and again in the transaction on the fresh row.
195
+ async function checkImageTarget(args: {
196
+ readonly ctx: HandlerContext;
197
+ readonly tenantId: TenantId;
198
+ readonly templateId?: string | undefined;
199
+ readonly key?: string | undefined;
200
+ }): Promise<WriteResult<ImageTarget>> {
201
+ const { ctx, tenantId, templateId, key } = args;
96
202
  let row: TemplateResourceRow | undefined;
97
203
  if (templateId !== undefined) {
98
- row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
99
- tenantId,
100
- id: templateId,
101
- });
204
+ row = await fetchOne<TemplateResourceRow>(
205
+ requireForTenant(ctx, tenantId).db,
206
+ templateResourcesTable,
207
+ { tenantId, id: templateId },
208
+ );
102
209
  if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
103
210
  }
211
+ const links = row === undefined ? {} : parseLinkedResourceEntries(row.linkedResources);
212
+ if (key !== undefined && isAttachmentKey(links, key)) return keyInUse();
213
+ return { isSuccess: true, data: { row, links } };
214
+ }
104
215
 
105
- const created = await createFileRefFromBytes(
106
- {
107
- db,
108
- files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
109
- registry: ctx.registry,
110
- user: { ...user, tenantId },
111
- },
216
+ // beforeTransaction half: normalizes the image and uploads it to the target tenant's storage.
217
+ // The caller is responsible for authorization; the function trusts tenantId.
218
+ export async function prepareTemplateResourceBeforeTransaction(args: {
219
+ readonly ctx: HandlerContext;
220
+ readonly user: SessionUser;
221
+ readonly tenantId: TenantId;
222
+ readonly bytes: Uint8Array;
223
+ readonly maxBytes: number;
224
+ readonly templateId?: string;
225
+ readonly key?: string;
226
+ }): Promise<BeforeTransactionResult<PreparedTemplateImage>> {
227
+ const { ctx, user, tenantId, bytes, maxBytes, templateId, key } = args;
228
+ const normalized = await normalizeUpload(bytes, maxBytes);
229
+ if ("failure" in normalized) return normalized.failure;
230
+ const { image } = normalized;
231
+
232
+ const target = await checkImageTarget({ ctx, tenantId, templateId, key });
233
+ if (!target.isSuccess) return target;
234
+
235
+ const uploaded = await uploadToTenant(
236
+ { ctx, user, tenantId },
112
237
  {
113
238
  // Neutral name: the source's file name may be personal data.
114
- fileName: `${key ?? "resource"}.${EXTENSION_BY_CONTENT_TYPE[image.contentType] ?? "bin"}`,
239
+ fileName: `${key ?? "resource"}.${extensionForResourceContentType(image.contentType) ?? "bin"}`,
115
240
  mimeType: image.contentType,
116
241
  data: image.data,
117
242
  },
118
243
  );
244
+ if (!uploaded.isSuccess) return uploaded;
245
+ return {
246
+ isSuccess: true,
247
+ data: {
248
+ kind: "image",
249
+ contentType: image.contentType,
250
+ width: image.width,
251
+ height: image.height,
252
+ size: image.data.length,
253
+ upload: uploaded.data,
254
+ },
255
+ ...(uploaded.release && { release: uploaded.release }),
256
+ };
257
+ }
258
+
259
+ export type Release = () => Promise<void>;
260
+
261
+ // Tries every release, so one failing delete does not strand the other uploads; the first rejection is rethrown.
262
+ export async function releaseAll(releases: readonly Release[]): Promise<void> {
263
+ const settled = await Promise.allSettled(releases.map((release) => release()));
264
+ const rejected = settled.find((outcome) => outcome.status === "rejected");
265
+ if (rejected?.status === "rejected") throw rejected.reason;
266
+ }
267
+
268
+ // `upload` is absent for a rendered-PDF link, which carries no file.
269
+ export type PreparedLinkedResourceCopy = {
270
+ readonly key: string;
271
+ readonly entry: LinkedResourceEntry;
272
+ readonly upload?: UploadedFileBytes;
273
+ };
274
+
275
+ // beforeTransaction half: copies the file behind every entry into the target tenant's storage, so a
276
+ // template copied from the system default keeps working once the tenant exports it or measures its
277
+ // attachments. An entry whose source file is gone is left out: it could not be resolved at render
278
+ // time either. A failed or throwing upload releases the ones before it.
279
+ export async function prepareLinkedResourceCopiesBeforeTransaction(args: {
280
+ readonly ctx: HandlerContext;
281
+ readonly user: SessionUser;
282
+ readonly tenantId: TenantId;
283
+ readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
284
+ }): Promise<BeforeTransactionResult<readonly PreparedLinkedResourceCopy[]>> {
285
+ const { ctx, user, tenantId, resources } = args;
286
+ const copies: PreparedLinkedResourceCopy[] = [];
287
+ const releases: Release[] = [];
288
+ try {
289
+ for (const [key, entry] of Object.entries(resources)) {
290
+ if (typeof entry !== "string" && entry.kind === "rendered-pdf") {
291
+ copies.push({ key, entry });
292
+ continue;
293
+ }
294
+ const file = await readTemplateResource(
295
+ ctx,
296
+ typeof entry === "string" ? entry : entry.fileRefId,
297
+ );
298
+ if (file === null) continue;
299
+ const uploaded = await uploadToTenant(
300
+ { ctx, user, tenantId },
301
+ { fileName: file.fileName, mimeType: file.contentType, data: file.bytes },
302
+ );
303
+ if (!uploaded.isSuccess) {
304
+ await releaseAll(releases);
305
+ return uploaded;
306
+ }
307
+ if (uploaded.release) releases.push(uploaded.release);
308
+ copies.push({ key, entry, upload: uploaded.data });
309
+ }
310
+ } catch (cause) {
311
+ await releaseAll(releases);
312
+ throw cause;
313
+ }
314
+ return { isSuccess: true, data: copies, release: () => releaseAll(releases) };
315
+ }
316
+
317
+ // Transaction half: creates the target tenant's fileRef rows and returns the entries pointing at them.
318
+ export async function createLinkedResourceCopies(
319
+ scope: TenantScope,
320
+ prepared: readonly PreparedLinkedResourceCopy[],
321
+ ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
322
+ const linked: Record<string, LinkedResourceEntry> = {};
323
+ for (const { key, entry, upload } of prepared) {
324
+ if (typeof entry !== "string" && entry.kind === "rendered-pdf") {
325
+ linked[key] = entry;
326
+ continue;
327
+ }
328
+ // skip: a file entry without a copy must not carry the source tenant's fileRefId over
329
+ if (upload === undefined) continue;
330
+ const created = await createFileRefFromPrepared(scope, upload);
331
+ if (!created.isSuccess) return created;
332
+ linked[key] =
333
+ typeof entry === "string" ? created.data.id : { ...entry, fileRefId: created.data.id };
334
+ }
335
+ return { isSuccess: true, data: linked };
336
+ }
337
+
338
+ // Transaction half: creates the fileRef row for the prepared upload and, with templateId + key,
339
+ // links it into the target tenant's template.
340
+ export async function storePreparedTemplateResource(args: {
341
+ readonly ctx: HandlerContext;
342
+ readonly user: SessionUser;
343
+ readonly tenantId: TenantId;
344
+ readonly prepared: PreparedTemplateImage;
345
+ readonly templateId?: string;
346
+ readonly key?: string;
347
+ }): Promise<WriteResult<StoredTemplateResource>> {
348
+ const { ctx, user, tenantId, prepared, templateId, key } = args;
349
+ const target = await checkImageTarget({ ctx, tenantId, templateId, key });
350
+ if (!target.isSuccess) return target;
351
+ const { row, links } = target.data;
352
+
353
+ const created = await createFileRefFromPrepared({ ctx, user, tenantId }, prepared.upload);
119
354
  if (!created.isSuccess) return created;
120
355
 
121
356
  if (row !== undefined && key !== undefined) {
122
- // The replaced FileRef stays: copy-locale and tenant overrides can share ids and
123
- // there is no reference count to tell when the last user is gone.
124
- const linkedResources = {
125
- ...parseLinkedResourceEntries(row.linkedResources),
126
- [key]: created.data.id,
127
- };
128
- const updated = await executor.update(
129
- {
130
- id: row.id,
131
- version: row.version,
132
- changes: { linkedResources: JSON.stringify(linkedResources) },
133
- },
357
+ const linked = await linkImage({
358
+ ctx,
134
359
  user,
135
- db,
136
- { streamTenantId },
137
- );
138
- if (!updated.isSuccess) return updated;
360
+ tenantId,
361
+ row,
362
+ key,
363
+ links,
364
+ fileRefId: created.data.id,
365
+ });
366
+ if (!linked.isSuccess) return linked;
139
367
  }
140
368
 
141
369
  return {
@@ -143,10 +371,10 @@ export async function storeTemplateResource(args: {
143
371
  data: {
144
372
  fileRefId: created.data.id,
145
373
  ...(key !== undefined && { key }),
146
- contentType: image.contentType,
147
- width: image.width,
148
- height: image.height,
149
- size: image.data.length,
374
+ contentType: prepared.contentType,
375
+ width: prepared.width,
376
+ height: prepared.height,
377
+ size: prepared.size,
150
378
  },
151
379
  };
152
380
  }
@@ -228,36 +456,39 @@ function inspectAttachmentBytes(
228
456
  return { type };
229
457
  }
230
458
 
231
- // Stores the bytes unchanged as a file of the target tenant, without linking it anywhere.
232
- async function createAttachmentFileRef(args: {
459
+ type AttachmentTarget = {
460
+ readonly row: TemplateResourceRow;
461
+ readonly linked: Readonly<Record<string, LinkedResourceEntry>>;
462
+ };
463
+
464
+ // Runs before the upload (reject early, store nothing) and again in the transaction on the fresh row.
465
+ async function checkAttachmentTarget(args: {
233
466
  readonly ctx: HandlerContext;
234
- readonly user: SessionUser;
235
467
  readonly tenantId: TenantId;
236
- readonly bytes: Uint8Array;
468
+ readonly templateId: string;
237
469
  readonly key: string;
238
- readonly type: AttachmentType;
239
- }): Promise<WriteResult<{ readonly id: string }>> {
240
- const { ctx, user, tenantId, bytes, key, type } = args;
241
- const { db } = requireForTenant(ctx, tenantId);
242
- const created = await createFileRefFromBytes(
243
- {
244
- db,
245
- files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
246
- registry: ctx.registry,
247
- user: { ...user, tenantId },
248
- },
249
- {
250
- // Neutral stored name: the admin's file name may be personal data and lives in the link entry.
251
- fileName: `${key}.${type.extension}`,
252
- mimeType: type.contentType,
253
- data: bytes,
254
- },
470
+ }): Promise<WriteResult<AttachmentTarget>> {
471
+ const { ctx, tenantId, templateId, key } = args;
472
+ const row = await fetchOne<TemplateResourceRow>(
473
+ requireForTenant(ctx, tenantId).db,
474
+ templateResourcesTable,
475
+ { tenantId, id: templateId },
255
476
  );
256
- return created.isSuccess ? { isSuccess: true, data: { id: created.data.id } } : created;
477
+ if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
478
+ if (row.kind !== "mail") return attachmentNeedsMailTemplate();
479
+
480
+ const linked = parseLinkedResourceEntries(row.linkedResources);
481
+ if (otherAttachmentCount(linked, key) >= MAX_FILE_ATTACHMENTS_PER_TEMPLATE) {
482
+ return attachmentLimitFailure();
483
+ }
484
+ const occupant = linked[key];
485
+ if (occupant !== undefined && typeof occupant === "string") return keyInUse();
486
+ return { isSuccess: true, data: { row, linked } };
257
487
  }
258
488
 
259
- // Stores a PDF/PNG/JPEG for a bundle import: validated like an upload, linked later by the importer.
260
- export async function storeBundleAttachment(args: {
489
+ // beforeTransaction half: validates a PDF/PNG/JPEG and uploads it unchanged to the target tenant's
490
+ // storage. With templateId the template is checked first; a bundle import has none yet.
491
+ export async function prepareTemplateAttachmentBeforeTransaction(args: {
261
492
  readonly ctx: HandlerContext;
262
493
  readonly user: SessionUser;
263
494
  readonly tenantId: TenantId;
@@ -265,51 +496,81 @@ export async function storeBundleAttachment(args: {
265
496
  readonly maxBytes: number;
266
497
  readonly key: string;
267
498
  readonly fileName: string;
268
- }): Promise<WriteResult<FileAttachmentRef>> {
269
- const inspected = inspectAttachmentBytes(args.bytes, args.maxBytes);
499
+ readonly templateId?: string;
500
+ }): Promise<BeforeTransactionResult<PreparedTemplateAttachment>> {
501
+ const { ctx, user, tenantId, bytes, maxBytes, key, fileName, templateId } = args;
502
+ const inspected = inspectAttachmentBytes(bytes, maxBytes);
270
503
  if ("failure" in inspected) return inspected.failure;
271
- const created = await createAttachmentFileRef({ ...args, type: inspected.type });
504
+ const { type } = inspected;
505
+
506
+ if (templateId !== undefined) {
507
+ const target = await checkAttachmentTarget({ ctx, tenantId, templateId, key });
508
+ if (!target.isSuccess) return target;
509
+ }
510
+
511
+ const uploaded = await uploadToTenant(
512
+ { ctx, user, tenantId },
513
+ {
514
+ // Neutral stored name: the admin's file name may be personal data and lives in the link entry.
515
+ fileName: `${key}.${type.extension}`,
516
+ mimeType: type.contentType,
517
+ data: bytes,
518
+ },
519
+ );
520
+ if (!uploaded.isSuccess) return uploaded;
521
+ return {
522
+ isSuccess: true,
523
+ data: {
524
+ kind: "attachment",
525
+ contentType: type.contentType,
526
+ size: bytes.length,
527
+ fileName,
528
+ upload: uploaded.data,
529
+ },
530
+ ...(uploaded.release && { release: uploaded.release }),
531
+ };
532
+ }
533
+
534
+ // Creates the fileRef rows for a bundle's prepared uploads; the importer links the entries.
535
+ export async function createPreparedBundleResourceEntry(
536
+ scope: TenantScope,
537
+ prepared: PreparedTemplateFile,
538
+ ): Promise<WriteResult<LinkedResourceEntry>> {
539
+ const created = await createFileRefFromPrepared(scope, prepared.upload);
272
540
  if (!created.isSuccess) return created;
273
541
  return {
274
542
  isSuccess: true,
275
- data: { kind: "file", fileRefId: created.data.id, fileName: args.fileName },
543
+ data:
544
+ prepared.kind === "image"
545
+ ? created.data.id
546
+ : { kind: "file", fileRefId: created.data.id, fileName: prepared.fileName },
276
547
  };
277
548
  }
278
549
 
279
- // Stores a PDF/PNG/JPEG byte-for-byte (no re-encoding, the recipient gets what the admin uploaded)
280
- // and links it under `key` in a mail template of the target tenant. The caller authorizes tenantId.
281
- export async function storeTemplateAttachment(args: {
550
+ // Transaction half: creates the fileRef row for the prepared upload and links it under `key` in a
551
+ // mail template of the target tenant.
552
+ export async function storePreparedTemplateAttachment(args: {
282
553
  readonly ctx: HandlerContext;
283
554
  readonly user: SessionUser;
284
555
  readonly tenantId: TenantId;
285
- readonly bytes: Uint8Array;
286
- readonly maxBytes: number;
556
+ readonly prepared: PreparedTemplateAttachment;
287
557
  readonly templateId: string;
288
558
  readonly key: string;
289
- readonly fileName: string;
290
559
  }): Promise<WriteResult<StoredTemplateAttachment>> {
291
- const { ctx, user, tenantId, bytes, maxBytes, templateId, key, fileName } = args;
292
- const inspected = inspectAttachmentBytes(bytes, maxBytes);
293
- if ("failure" in inspected) return inspected.failure;
294
- const { type } = inspected;
295
-
296
- const { db, streamTenantId } = requireForTenant(ctx, tenantId);
297
- const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
298
- tenantId,
299
- id: templateId,
300
- });
301
- if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
302
- if (row.kind !== "mail") return attachmentNeedsMailTemplate();
303
-
304
- const linked = parseLinkedResourceEntries(row.linkedResources);
305
- if (otherAttachmentCount(linked, key) >= MAX_FILE_ATTACHMENTS_PER_TEMPLATE) {
306
- return attachmentLimitFailure();
307
- }
560
+ const { ctx, user, tenantId, prepared, templateId, key } = args;
561
+ const target = await checkAttachmentTarget({ ctx, tenantId, templateId, key });
562
+ if (!target.isSuccess) return target;
563
+ const { row, linked } = target.data;
308
564
 
309
- const created = await createAttachmentFileRef({ ctx, user, tenantId, bytes, key, type });
565
+ const created = await createFileRefFromPrepared({ ctx, user, tenantId }, prepared.upload);
310
566
  if (!created.isSuccess) return created;
311
567
 
312
- const entry: LinkedResourceEntry = { kind: "file", fileRefId: created.data.id, fileName };
568
+ const entry: LinkedResourceEntry = {
569
+ kind: "file",
570
+ fileRefId: created.data.id,
571
+ fileName: prepared.fileName,
572
+ };
573
+ const { db, streamTenantId } = requireForTenant(ctx, tenantId);
313
574
  const updated = await executor.update(
314
575
  {
315
576
  id: row.id,
@@ -327,9 +588,9 @@ export async function storeTemplateAttachment(args: {
327
588
  data: {
328
589
  fileRefId: created.data.id,
329
590
  key,
330
- fileName,
331
- contentType: type.contentType,
332
- size: bytes.length,
591
+ fileName: prepared.fileName,
592
+ contentType: prepared.contentType,
593
+ size: prepared.size,
333
594
  },
334
595
  };
335
596
  }
@@ -384,7 +645,12 @@ export async function linkTemplatePdfAttachment(args: {
384
645
  const visibleTemplates = await selectMany<TemplateResourceRow>(
385
646
  db,
386
647
  templateResourcesTable,
387
- { tenantId: [tenantId, SYSTEM_TENANT_ID], slug: link.data.templateSlug, kind: "document-pdf" },
648
+ {
649
+ tenantId: [tenantId, SYSTEM_TENANT_ID],
650
+ slug: link.data.templateSlug,
651
+ kind: "document-pdf",
652
+ status: ACTIVE_TEMPLATE_STATUS,
653
+ },
388
654
  { limit: 1 },
389
655
  );
390
656
  if (visibleTemplates.length === 0) {
@@ -5,6 +5,7 @@ import { templatePdfAttachmentLinkSchema } from "./linked-resources.js";
5
5
  import { templateSlugSchema } from "./template-meta.js";
6
6
 
7
7
  const MAX_RESOURCES = 20;
8
+ const MAX_LOCALES = 50;
8
9
  const MAX_RESOURCE_BYTES = 2 * 1024 * 1024;
9
10
  const MAX_BASE64_LENGTH = Math.ceil(MAX_RESOURCE_BYTES / 3) * 4;
10
11
 
@@ -52,6 +53,9 @@ export const templateBundleSchema = z
52
53
  .record(bundleLocaleSchema, bundleLocaleEntrySchema)
53
54
  .refine((locales) => Object.keys(locales).length >= 1, {
54
55
  message: "a bundle needs at least one locale",
56
+ })
57
+ .refine((locales) => Object.keys(locales).length <= MAX_LOCALES, {
58
+ message: `a bundle holds at most ${MAX_LOCALES} locales`,
55
59
  }),
56
60
  resources: z
57
61
  .record(
@@ -57,7 +57,7 @@ export type ActiveInfo = { readonly version: number; readonly since: Temporal.In
57
57
  export type WorkingRow = TemplateResourceRow & { readonly active: ActiveInfo | null };
58
58
 
59
59
  // A SystemAdmin edits system rows, so for them those rows count as their own.
60
- function isSystemAdmin(user: { readonly roles: readonly string[] }): boolean {
60
+ export function isSystemAdmin(user: { readonly roles: readonly string[] }): boolean {
61
61
  return user.roles.includes("SystemAdmin");
62
62
  }
63
63
 
@@ -5,6 +5,7 @@ import {
5
5
  PDF_PAGE_FORMATS,
6
6
  type TemplateKind,
7
7
  } from "./constants.js";
8
+ import { MAIL_TONES, type MailTone } from "./mail-constants.js";
8
9
 
9
10
  // One or more kebab segments joined by ":" — notification templates are seeded
10
11
  // under their qualified name `feature:notify:name`.
@@ -20,6 +21,8 @@ export const templateSlugSchema = z
20
21
  export const MAIL_CATEGORIES = ["transactional", "marketing"] as const;
21
22
  export type MailCategory = (typeof MAIL_CATEGORIES)[number];
22
23
 
24
+ export { MAIL_TONES, type MailTone };
25
+
23
26
  // Header values: a CR/LF would let an author inject extra mail headers.
24
27
  const singleLine = (max: number) =>
25
28
  z
@@ -44,6 +47,11 @@ const mailMetaSchema = z.preprocess(
44
47
  category: z.enum(MAIL_CATEGORIES).optional(),
45
48
  senderName: singleLine(80).min(1).optional(),
46
49
  replyTo: singleLine(254).pipe(z.email()).optional(),
50
+ // Placeholder lines like the subject. The tone renders to a MAIL_TONES member;
51
+ // anything else falls back to the accent tone.
52
+ headerChip: singleLine(80).optional(),
53
+ tone: singleLine(80).optional(),
54
+ footerNote: singleLine(300).optional(),
47
55
  })
48
56
  .strict(),
49
57
  );