@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.
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +13 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +12 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +81 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +6 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
- package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +12 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { TemplateVariableDef, TemplateVariables } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import type { Element as ScanElement } from "happy-dom";
|
|
4
|
+
import { isResourceImageContentType } from "../shared/index.js";
|
|
5
|
+
import { rewriteStepsBlock } from "./compile-steps-block.js";
|
|
4
6
|
import { withParsedDocument } from "./document-sanitizer.js";
|
|
5
7
|
import {
|
|
6
8
|
DOCUMENT_TEXT_ALIGNMENTS,
|
|
@@ -83,6 +85,39 @@ function pathAndDef(
|
|
|
83
85
|
return [path, def];
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
const VARIABLE_ONLY_BLOCK_SELECTOR = "p, h1, h2, h3, h4";
|
|
89
|
+
|
|
90
|
+
function soleVariableName(block: ScanElement): string | null {
|
|
91
|
+
const names = new Set(
|
|
92
|
+
Array.from(block.querySelectorAll("span[data-variable]")).map(
|
|
93
|
+
(span) => span.getAttribute("data-variable") ?? "",
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
const [name] = [...names];
|
|
97
|
+
if (names.size !== 1 || name === undefined || !isDocumentReferenceName(name)) return null;
|
|
98
|
+
const withoutVariables = block.cloneNode(true);
|
|
99
|
+
for (const span of Array.from(withoutVariables.querySelectorAll("span[data-variable]"))) {
|
|
100
|
+
span.remove();
|
|
101
|
+
}
|
|
102
|
+
const hasStaticContent =
|
|
103
|
+
(withoutVariables.textContent ?? "").trim() !== "" ||
|
|
104
|
+
withoutVariables.querySelector("img") !== null;
|
|
105
|
+
return hasStaticContent ? null : name;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A line that holds nothing but one optional placeholder would otherwise leave
|
|
109
|
+
// an empty paragraph (a visible gap) when the value is missing. includeZero
|
|
110
|
+
// keeps an amount of 0.
|
|
111
|
+
function wrapVariableOnlyBlock(block: ScanElement, state: CompileState): void {
|
|
112
|
+
const name = soleVariableName(block);
|
|
113
|
+
// skip: a line with static content or several placeholders stays as it is
|
|
114
|
+
if (name === null) return;
|
|
115
|
+
const [path] = pathAndDef(name, block, state);
|
|
116
|
+
const document = block.ownerDocument;
|
|
117
|
+
block.before(document.createTextNode(marker(state, `{{#if ${path} includeZero=true}}`)));
|
|
118
|
+
block.after(document.createTextNode(marker(state, "{{/if}}")));
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
function rewriteLink(element: ScanElement, state: CompileState): void {
|
|
87
122
|
const href = element.getAttribute("href") ?? "";
|
|
88
123
|
if (isDocumentPlaceholderHref(href)) {
|
|
@@ -126,6 +161,8 @@ export type CompileDocumentBodyOptions = {
|
|
|
126
161
|
readonly resourceSrc?: (key: string) => string | undefined;
|
|
127
162
|
// Mail clients ignore document CSS, so a full-width logo needs an inline width cap.
|
|
128
163
|
readonly resourceImageMaxWidthPx?: number;
|
|
164
|
+
// Keeps a square logo in a mail header from growing to the full width cap.
|
|
165
|
+
readonly resourceImageMaxHeightPx?: number;
|
|
129
166
|
// Mail clients ignore document CSS, so mail turns data-align into an inline style.
|
|
130
167
|
readonly alignmentAsInlineStyle?: boolean;
|
|
131
168
|
// Same reason: a[data-button] only looks like a button through editor/print CSS.
|
|
@@ -135,6 +172,10 @@ export type CompileDocumentBodyOptions = {
|
|
|
135
172
|
readonly tableAsInlineStyle?: boolean;
|
|
136
173
|
// Replaces the default button look; only values from the layout appearance belong here.
|
|
137
174
|
readonly buttonInlineStyle?: string;
|
|
175
|
+
// Mail clients ignore document CSS, so headings get their size and weight inline.
|
|
176
|
+
readonly headingAsInlineStyle?: boolean;
|
|
177
|
+
// Bar color of reached stages in a steps block; ignored unless it is a #rrggbb hex.
|
|
178
|
+
readonly stepsColor?: string;
|
|
138
179
|
};
|
|
139
180
|
|
|
140
181
|
const MAIL_BUTTON_STYLE =
|
|
@@ -143,7 +184,12 @@ const MAIL_BUTTON_STYLE =
|
|
|
143
184
|
const MAIL_TABLE_STYLE = "width:100%;border-collapse:collapse";
|
|
144
185
|
const MAIL_TABLE_CELL_STYLE = "padding:6px 12px;vertical-align:top;border-bottom:1px solid #e5e5e5";
|
|
145
186
|
const MAIL_TABLE_HEAD_STYLE = "font-size:12px;font-weight:600;color:#6b7280;text-align:left";
|
|
146
|
-
const MAIL_SMALL_STYLE = "font-size:
|
|
187
|
+
const MAIL_SMALL_STYLE = "font-size:13px;color:#4A515E";
|
|
188
|
+
const MAIL_HEADING_STYLES: Readonly<Record<string, string>> = {
|
|
189
|
+
h1: "margin:0 0 12px 0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-0.035em;color:#0B0D12",
|
|
190
|
+
h2: "margin:0 0 12px 0;font-size:24px;line-height:1.2;font-weight:700;letter-spacing:-0.02em;color:#0B0D12",
|
|
191
|
+
h3: "margin:20px 0 8px 0;font-size:17px;line-height:1.3;font-weight:600;color:#0B0D12",
|
|
192
|
+
};
|
|
147
193
|
|
|
148
194
|
// Appended after an existing style (data-align) so the alignment survives.
|
|
149
195
|
function appendStyle(element: ScanElement, style: string): void {
|
|
@@ -151,13 +197,6 @@ function appendStyle(element: ScanElement, style: string): void {
|
|
|
151
197
|
element.setAttribute("style", existing === null ? style : `${existing};${style}`);
|
|
152
198
|
}
|
|
153
199
|
|
|
154
|
-
const RESOURCE_IMAGE_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
|
155
|
-
"image/png",
|
|
156
|
-
"image/jpeg",
|
|
157
|
-
"image/gif",
|
|
158
|
-
"image/webp",
|
|
159
|
-
]);
|
|
160
|
-
|
|
161
200
|
// Unique valid resource keys in document order.
|
|
162
201
|
export function collectDocumentResourceKeys(html: string): readonly string[] {
|
|
163
202
|
return withParsedDocument(html, (root) => {
|
|
@@ -171,7 +210,7 @@ export function collectDocumentResourceKeys(html: string): readonly string[] {
|
|
|
171
210
|
}
|
|
172
211
|
|
|
173
212
|
export function toResourceDataUri(contentType: string, bytes: Uint8Array): string {
|
|
174
|
-
if (!
|
|
213
|
+
if (!isResourceImageContentType(contentType)) {
|
|
175
214
|
throw new Error(`resource content type "${contentType}" cannot be embedded as an image`);
|
|
176
215
|
}
|
|
177
216
|
return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
@@ -188,6 +227,7 @@ function rewriteResourceImage(
|
|
|
188
227
|
element: ScanElement,
|
|
189
228
|
resourceSrc: CompileDocumentBodyOptions["resourceSrc"],
|
|
190
229
|
maxWidthPx: number | undefined,
|
|
230
|
+
maxHeightPx: number | undefined,
|
|
191
231
|
): void {
|
|
192
232
|
const key = element.getAttribute("data-resource") ?? "";
|
|
193
233
|
const src = isDocumentResourceKey(key) ? resourceSrc?.(key) : undefined;
|
|
@@ -198,8 +238,11 @@ function rewriteResourceImage(
|
|
|
198
238
|
}
|
|
199
239
|
element.setAttribute("src", src);
|
|
200
240
|
element.removeAttribute("data-resource");
|
|
201
|
-
|
|
202
|
-
|
|
241
|
+
const caps = [
|
|
242
|
+
...(maxWidthPx === undefined ? [] : [`max-width:${maxWidthPx}px`]),
|
|
243
|
+
...(maxHeightPx === undefined ? [] : [`max-height:${maxHeightPx}px;width:auto`]),
|
|
244
|
+
];
|
|
245
|
+
if (caps.length > 0) element.setAttribute("style", `${caps.join(";")};height:auto`);
|
|
203
246
|
}
|
|
204
247
|
|
|
205
248
|
function stripScriptsAndActiveContent(
|
|
@@ -208,7 +251,12 @@ function stripScriptsAndActiveContent(
|
|
|
208
251
|
): void {
|
|
209
252
|
for (const element of Array.from(root.querySelectorAll("script"))) element.remove();
|
|
210
253
|
for (const element of Array.from(root.querySelectorAll("img[data-resource]"))) {
|
|
211
|
-
rewriteResourceImage(
|
|
254
|
+
rewriteResourceImage(
|
|
255
|
+
element,
|
|
256
|
+
options.resourceSrc,
|
|
257
|
+
options.resourceImageMaxWidthPx,
|
|
258
|
+
options.resourceImageMaxHeightPx,
|
|
259
|
+
);
|
|
212
260
|
}
|
|
213
261
|
for (const element of Array.from(root.querySelectorAll("*"))) stripActiveContent(element);
|
|
214
262
|
}
|
|
@@ -223,6 +271,13 @@ function applyAlignmentInlineStyles(root: ScanElement): void {
|
|
|
223
271
|
}
|
|
224
272
|
}
|
|
225
273
|
|
|
274
|
+
function applyHeadingInlineStyles(root: ScanElement): void {
|
|
275
|
+
for (const heading of Array.from(root.querySelectorAll("h1, h2, h3"))) {
|
|
276
|
+
const style = MAIL_HEADING_STYLES[heading.tagName.toLowerCase()];
|
|
277
|
+
if (style !== undefined) appendStyle(heading, style);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
226
281
|
function applyTableInlineStyles(root: ScanElement): void {
|
|
227
282
|
for (const table of Array.from(root.querySelectorAll("table"))) {
|
|
228
283
|
appendStyle(table, MAIL_TABLE_STYLE);
|
|
@@ -261,7 +316,18 @@ export function compileDocumentBodyToHandlebars(
|
|
|
261
316
|
const compiled = withParsedDocument(scrubbed, (root) => {
|
|
262
317
|
stripScriptsAndActiveContent(root, options);
|
|
263
318
|
if (options.alignmentAsInlineStyle === true) applyAlignmentInlineStyles(root);
|
|
319
|
+
if (options.headingAsInlineStyle === true) applyHeadingInlineStyles(root);
|
|
264
320
|
if (options.tableAsInlineStyle === true) applyTableInlineStyles(root);
|
|
321
|
+
for (const element of Array.from(root.querySelectorAll("div[data-steps]"))) {
|
|
322
|
+
rewriteStepsBlock(element, {
|
|
323
|
+
expression: (source) => marker(state, source),
|
|
324
|
+
pathOf: (name, at) => pathAndDef(name, at, state)[0],
|
|
325
|
+
color: options.stepsColor,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
for (const block of Array.from(root.querySelectorAll(VARIABLE_ONLY_BLOCK_SELECTOR))) {
|
|
329
|
+
wrapVariableOnlyBlock(block, state);
|
|
330
|
+
}
|
|
265
331
|
for (const element of Array.from(root.querySelectorAll("span[data-variable]"))) {
|
|
266
332
|
rewriteVariable(element, state);
|
|
267
333
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Element as ScanElement } from "happy-dom";
|
|
2
|
+
import { isDocumentReferenceName } from "./document-schema.js";
|
|
3
|
+
import { readDocumentSteps } from "./document-steps.js";
|
|
4
|
+
import { isHexColor } from "./layout-appearance.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_STEPS_COLOR = "#1f5468";
|
|
7
|
+
const TODO_BAR_COLOR = "#ECEDEA";
|
|
8
|
+
const REACHED_LABEL_COLOR = "#0B0D12";
|
|
9
|
+
const TODO_LABEL_COLOR = "#5A6170";
|
|
10
|
+
|
|
11
|
+
export type StepsCompileHooks = {
|
|
12
|
+
/** Registers a Handlebars expression and returns the marker that stands for it. */
|
|
13
|
+
readonly expression: (source: string) => string;
|
|
14
|
+
/** Resolves a variable name to its Handlebars path at the element's position. */
|
|
15
|
+
readonly pathOf: (name: string, element: ScanElement) => string;
|
|
16
|
+
readonly color: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Inline-styled table in every output (mail, PDF, preview): mail clients ignore
|
|
20
|
+
// document CSS, and one rendering keeps the three outputs identical. The labels
|
|
21
|
+
// stay static text; only the reached/current switch is a Handlebars block, and
|
|
22
|
+
// its arguments are a validated path and validated keys.
|
|
23
|
+
export function rewriteStepsBlock(element: ScanElement, hooks: StepsCompileHooks): void {
|
|
24
|
+
const name = element.getAttribute("data-steps") ?? "";
|
|
25
|
+
const steps = readDocumentSteps(element);
|
|
26
|
+
if (!isDocumentReferenceName(name) || steps === null) {
|
|
27
|
+
element.remove();
|
|
28
|
+
// skip: an invalid steps block renders nothing
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const path = hooks.pathOf(name, element);
|
|
32
|
+
const keys = steps.map((step) => step.key).join(",");
|
|
33
|
+
const color =
|
|
34
|
+
hooks.color !== undefined && isHexColor(hooks.color) ? hooks.color : DEFAULT_STEPS_COLOR;
|
|
35
|
+
const when = (index: number, state: "reached" | "current", yes: string, no: string): string =>
|
|
36
|
+
`${hooks.expression(`{{#stepIs ${path} ${index} "${keys}" "${state}"}}`)}${yes}${hooks.expression("{{else}}")}${no}${hooks.expression("{{/stepIs}}")}`;
|
|
37
|
+
|
|
38
|
+
const document = element.ownerDocument;
|
|
39
|
+
const table = document.createElement("table");
|
|
40
|
+
table.setAttribute("role", "presentation");
|
|
41
|
+
table.setAttribute("width", "100%");
|
|
42
|
+
table.setAttribute("cellpadding", "0");
|
|
43
|
+
table.setAttribute("cellspacing", "0");
|
|
44
|
+
table.setAttribute("style", "table-layout:fixed;border-collapse:collapse;margin:4px 0 24px 0");
|
|
45
|
+
const row = document.createElement("tr");
|
|
46
|
+
steps.forEach((step, index) => {
|
|
47
|
+
const cell = document.createElement("td");
|
|
48
|
+
cell.setAttribute(
|
|
49
|
+
"style",
|
|
50
|
+
`padding:0 ${index === steps.length - 1 ? 0 : 4}px 0 0;vertical-align:top`,
|
|
51
|
+
);
|
|
52
|
+
const bar = document.createElement("div");
|
|
53
|
+
bar.setAttribute(
|
|
54
|
+
"style",
|
|
55
|
+
`height:4px;line-height:4px;font-size:0;border-radius:2px;background-color:${when(index, "reached", color, TODO_BAR_COLOR)}`,
|
|
56
|
+
);
|
|
57
|
+
const label = document.createElement("div");
|
|
58
|
+
label.setAttribute(
|
|
59
|
+
"style",
|
|
60
|
+
`padding-top:8px;font-size:12px;line-height:1.3;color:${when(index, "reached", REACHED_LABEL_COLOR, TODO_LABEL_COLOR)};font-weight:${when(index, "current", "600", "400")}`,
|
|
61
|
+
);
|
|
62
|
+
label.textContent = step.label;
|
|
63
|
+
cell.append(bar, label);
|
|
64
|
+
row.append(cell);
|
|
65
|
+
});
|
|
66
|
+
table.append(row);
|
|
67
|
+
element.replaceWith(table);
|
|
68
|
+
}
|
|
@@ -51,7 +51,15 @@ export const TEXT_BLOCK_KIND = "text-block" as const;
|
|
|
51
51
|
// Kinds a content collection must not write: a tenant row of these kinds would
|
|
52
52
|
// shadow the system notification template or layout without the meta and
|
|
53
53
|
// placeholder gates that upsertTenant applies.
|
|
54
|
-
export const COLLECTION_REFUSED_KINDS
|
|
54
|
+
export const COLLECTION_REFUSED_KINDS = [
|
|
55
|
+
"mail",
|
|
56
|
+
"mail-layout",
|
|
57
|
+
"pdf-layout",
|
|
58
|
+
] as const satisfies readonly TemplateKind[];
|
|
59
|
+
|
|
60
|
+
export function isCollectionRefusedKind(kind: string): boolean {
|
|
61
|
+
return COLLECTION_REFUSED_KINDS.some((refused) => refused === kind);
|
|
62
|
+
}
|
|
55
63
|
|
|
56
64
|
export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
|
|
57
65
|
export type ContentFormat = (typeof CONTENT_FORMATS)[number];
|
|
@@ -61,6 +69,7 @@ export type TemplateScope = (typeof TEMPLATE_SCOPES)[number];
|
|
|
61
69
|
|
|
62
70
|
export const TEMPLATE_STATUSES = ["draft", "active", "archived"] as const;
|
|
63
71
|
export type TemplateStatus = (typeof TEMPLATE_STATUSES)[number];
|
|
72
|
+
export const ACTIVE_TEMPLATE_STATUS = "active" satisfies TemplateStatus;
|
|
64
73
|
|
|
65
74
|
export {
|
|
66
75
|
PDF_DEFAULT_MARGIN_MM,
|
|
@@ -76,6 +85,7 @@ export {
|
|
|
76
85
|
export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
77
86
|
|
|
78
87
|
export const DEFAULT_MAIL_LAYOUT_SLUG = "default";
|
|
88
|
+
export const DEFAULT_PDF_LAYOUT_SLUG = "default";
|
|
79
89
|
|
|
80
90
|
// The mail transport enforces no size limit of its own; 10 MiB is a common provider ceiling.
|
|
81
91
|
export const MAIL_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isDocumentReferenceName,
|
|
14
14
|
isDocumentResourceKey,
|
|
15
15
|
} from "./document-schema.js";
|
|
16
|
+
import { isDocumentStepKey } from "./document-steps.js";
|
|
16
17
|
|
|
17
18
|
export type DocumentViolationCode =
|
|
18
19
|
| "script_element"
|
|
@@ -138,6 +139,8 @@ const REFERENCE_ATTRIBUTES: ReadonlyArray<readonly [string, (value: string) => b
|
|
|
138
139
|
["data-variable", isDocumentReferenceName],
|
|
139
140
|
["data-each", isDocumentReferenceName],
|
|
140
141
|
["data-resource", isDocumentResourceKey],
|
|
142
|
+
["data-steps", isDocumentReferenceName],
|
|
143
|
+
["data-step", isDocumentStepKey],
|
|
141
144
|
];
|
|
142
145
|
|
|
143
146
|
function scanReferences(tag: string, element: ScanElement, out: DocumentViolation[]): void {
|
|
@@ -8,6 +8,7 @@ import { Extension, type Extensions, Node } from "@tiptap/core";
|
|
|
8
8
|
import { Link } from "@tiptap/extension-link";
|
|
9
9
|
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
10
10
|
import StarterKit from "@tiptap/starter-kit";
|
|
11
|
+
import { createDocumentStepsNode } from "./document-steps.js";
|
|
11
12
|
|
|
12
13
|
const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
|
|
13
14
|
const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
@@ -71,6 +72,7 @@ export const DOCUMENT_NODE_NAMES = [
|
|
|
71
72
|
"button",
|
|
72
73
|
"divider",
|
|
73
74
|
"pageBreak",
|
|
75
|
+
"steps",
|
|
74
76
|
] as const;
|
|
75
77
|
|
|
76
78
|
export const DocumentVariable = Node.create({
|
|
@@ -131,7 +133,8 @@ export const DocumentResource = Node.create({
|
|
|
131
133
|
export const DocumentButton = Node.create({
|
|
132
134
|
name: "button",
|
|
133
135
|
group: "block",
|
|
134
|
-
|
|
136
|
+
// A placeholder in the label ("View {{pageName}}") must survive the round trip.
|
|
137
|
+
content: "(text | variable)*",
|
|
135
138
|
marks: "",
|
|
136
139
|
defining: true,
|
|
137
140
|
addAttributes() {
|
|
@@ -179,6 +182,8 @@ export const DocumentPageBreak = Node.create({
|
|
|
179
182
|
},
|
|
180
183
|
});
|
|
181
184
|
|
|
185
|
+
export const DocumentSteps = createDocumentStepsNode(isDocumentReferenceName);
|
|
186
|
+
|
|
182
187
|
// A repeat row is a table row carrying `each`: prosemirror-tables requires
|
|
183
188
|
// the tableRow node type, so a separate node type is not an option.
|
|
184
189
|
export const DocumentTableRow = TableRow.extend({
|
|
@@ -340,6 +345,7 @@ export const documentExtensions: Extensions = [
|
|
|
340
345
|
DocumentButton,
|
|
341
346
|
DocumentDivider,
|
|
342
347
|
DocumentPageBreak,
|
|
348
|
+
DocumentSteps,
|
|
343
349
|
DocumentTextAlign,
|
|
344
350
|
DocumentTextSize,
|
|
345
351
|
];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// A progress row: fixed stages stored in the template, the reached stage read
|
|
3
|
+
// from a variable at render time. Universal like document-schema.ts.
|
|
4
|
+
import { Node } from "@tiptap/core";
|
|
5
|
+
|
|
6
|
+
// Keys end up as Handlebars string literals in the compiled body, so they are
|
|
7
|
+
// restricted to characters that cannot close or escape a literal.
|
|
8
|
+
const STEP_KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
|
|
9
|
+
export const DOCUMENT_STEPS_MAX = 8;
|
|
10
|
+
const STEP_LABEL_MAX_LENGTH = 40;
|
|
11
|
+
|
|
12
|
+
export type DocumentStep = { readonly key: string; readonly label: string };
|
|
13
|
+
|
|
14
|
+
export function isDocumentStepKey(value: string): boolean {
|
|
15
|
+
return STEP_KEY.test(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function documentStepKeyOf(label: string): string {
|
|
19
|
+
return label
|
|
20
|
+
.normalize("NFKD")
|
|
21
|
+
.replace(/\p{M}/gu, "")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
24
|
+
.replace(/^-+|-+$/g, "")
|
|
25
|
+
.slice(0, 40);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Editor input: "value=Label" entries separated by commas; a bare label derives its value.
|
|
29
|
+
export function parseDocumentStepsInput(text: string): readonly DocumentStep[] | null {
|
|
30
|
+
const steps = text
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((entry) => entry.trim())
|
|
33
|
+
.filter((entry) => entry !== "")
|
|
34
|
+
.map((entry): DocumentStep => {
|
|
35
|
+
const separator = entry.indexOf("=");
|
|
36
|
+
const label = (separator < 0 ? entry : entry.slice(separator + 1)).trim();
|
|
37
|
+
const key = separator < 0 ? documentStepKeyOf(entry) : entry.slice(0, separator).trim();
|
|
38
|
+
return { key, label: label.slice(0, STEP_LABEL_MAX_LENGTH) };
|
|
39
|
+
});
|
|
40
|
+
return areValidSteps(steps) ? steps : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function areValidSteps(steps: readonly DocumentStep[]): boolean {
|
|
44
|
+
return (
|
|
45
|
+
steps.length > 0 &&
|
|
46
|
+
steps.length <= DOCUMENT_STEPS_MAX &&
|
|
47
|
+
new Set(steps.map((step) => step.key)).size === steps.length &&
|
|
48
|
+
steps.every((step) => isDocumentStepKey(step.key) && step.label !== "")
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Duplicate or invalid keys would make the reached stage ambiguous, so the whole
|
|
53
|
+
// list is refused rather than repaired.
|
|
54
|
+
export function readDocumentSteps(element: {
|
|
55
|
+
querySelectorAll(selector: string): ArrayLike<{
|
|
56
|
+
getAttribute(name: string): string | null;
|
|
57
|
+
textContent: string | null;
|
|
58
|
+
}>;
|
|
59
|
+
}): readonly DocumentStep[] | null {
|
|
60
|
+
const steps = Array.from(element.querySelectorAll("span[data-step]")).map((span) => ({
|
|
61
|
+
key: span.getAttribute("data-step") ?? "",
|
|
62
|
+
label: (span.textContent ?? "").trim().slice(0, STEP_LABEL_MAX_LENGTH),
|
|
63
|
+
}));
|
|
64
|
+
return areValidSteps(steps) ? steps : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isDocumentStep(step: unknown): step is DocumentStep {
|
|
68
|
+
return (
|
|
69
|
+
typeof step === "object" &&
|
|
70
|
+
step !== null &&
|
|
71
|
+
"key" in step &&
|
|
72
|
+
"label" in step &&
|
|
73
|
+
typeof step.key === "string" &&
|
|
74
|
+
typeof step.label === "string"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Node attrs are untyped JSON; anything that is not a step entry is dropped.
|
|
79
|
+
export function readDocumentStepsAttribute(value: unknown): readonly DocumentStep[] {
|
|
80
|
+
return Array.isArray(value) ? value.filter(isDocumentStep) : [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createDocumentStepsNode(isReferenceName: (value: string) => boolean) {
|
|
84
|
+
return Node.create({
|
|
85
|
+
name: "steps",
|
|
86
|
+
group: "block",
|
|
87
|
+
atom: true,
|
|
88
|
+
addAttributes() {
|
|
89
|
+
return { variable: { default: null }, steps: { default: [] } };
|
|
90
|
+
},
|
|
91
|
+
parseHTML() {
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
tag: "div[data-steps]",
|
|
95
|
+
getAttrs: (element) => {
|
|
96
|
+
const variable = element.getAttribute("data-steps");
|
|
97
|
+
const steps = readDocumentSteps(element);
|
|
98
|
+
if (variable === null || !isReferenceName(variable) || steps === null) return false;
|
|
99
|
+
return { variable, steps };
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
},
|
|
104
|
+
renderHTML({ node }) {
|
|
105
|
+
return [
|
|
106
|
+
"div",
|
|
107
|
+
{ "data-steps": node.attrs["variable"] },
|
|
108
|
+
...readDocumentStepsAttribute(node.attrs["steps"]).map(
|
|
109
|
+
(step) => ["span", { "data-step": step.key }, step.label] as const,
|
|
110
|
+
),
|
|
111
|
+
];
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createSystemConfig,
|
|
4
4
|
defineFeature,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { isCollectionRefusedKind } from "./constants.js";
|
|
6
7
|
import { bySlugQuery } from "./handlers/by-slug.query.js";
|
|
7
8
|
import { byTenantQuery } from "./handlers/by-tenant.query.js";
|
|
8
9
|
import { makeCollectionItemQuery } from "./handlers/collection-item.query.js";
|
|
@@ -60,8 +61,19 @@ export type TemplateResolverOptions = {
|
|
|
60
61
|
readonly collections?: readonly ContentCollectionDefinition[];
|
|
61
62
|
};
|
|
62
63
|
|
|
64
|
+
function assertCollectionKindsAllowed(collections: readonly ContentCollectionDefinition[]): void {
|
|
65
|
+
for (const collection of collections) {
|
|
66
|
+
if (isCollectionRefusedKind(collection.kind)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`template-resolver: collection "${collection.id}" uses kind "${collection.kind}", which collections may not write`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}) {
|
|
64
75
|
const collections = opts.collections ?? [];
|
|
76
|
+
assertCollectionKindsAllowed(collections);
|
|
65
77
|
const hasUserOwned = collections.some((c) => c.ownership === "user");
|
|
66
78
|
return defineFeature("template-resolver", (r) => {
|
|
67
79
|
r.describe(
|
|
@@ -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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
):
|
|
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(
|
|
102
|
-
|
|
103
|
-
if (attachmentCount > 0 &&
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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 & {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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: "
|
|
52
|
+
path: "",
|
|
52
53
|
code: "kind_not_allowed",
|
|
53
54
|
i18nKey: "templateResolver.errors.collection.kindNotAllowed",
|
|
54
55
|
params: { detail: collection.kind },
|