@cosmicdrift/kumiko-samples 0.363.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +68 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
- package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
- package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/index.ts +12 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
- package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
|
@@ -1,4 +1,72 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "PDF collections show Page break instead of Button, and placeholder links no longer insert {{name}} text",
|
|
6
|
+
"detail": "The content editor for a `document-pdf` collection now offers the PDF block set (including Page break) instead of the mail set, chosen from the collection's kind. A link to a placeholder href such as `{{invoice.url}}` without a selection now links a variable node instead of inserting the placeholder as plain text."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.364.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Document sanitizer rejects prototype-style placeholder hrefs",
|
|
12
|
+
"detail": "`{{__proto__}}` and `{{constructor.prototype}}` are no longer accepted as link or button placeholder hrefs; only valid reference names pass."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "DocumentRepeatRow is removed, use DocumentTableRow",
|
|
18
|
+
"detail": "Removed the DocumentRepeatRow alias",
|
|
19
|
+
"migration": "Replace `DocumentRepeatRow` with `DocumentTableRow` (identical shape)."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.364.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "export-bundle for a system template with resources returns a validation error outside the system tenant",
|
|
25
|
+
"detail": "A TenantAdmin, or a SystemAdmin outside the system tenant, exporting a system template that links resource files now gets the `system_resources_need_system_tenant` validation error, the same code import-bundle-system uses, instead of a NotFound for the resource file."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.364.0",
|
|
29
|
+
"type": "fix",
|
|
30
|
+
"title": "send-test logs failed test mails with error class and tenant",
|
|
31
|
+
"detail": "A transport that throws or reports not sent is logged with the error class/code and tenant id (no address, no provider body) and still answers `template_test_mail_failed`."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.364.0",
|
|
35
|
+
"type": "fix",
|
|
36
|
+
"title": "copy-locale ignores archived tenant sources and duplicates linked files of system sources; export-bundle refuses locales with differing format",
|
|
37
|
+
"detail": "copy-locale no longer copies from an archived tenant template, and copying a system template duplicates its linked resource files into the target tenant. export-bundle rejects a bundle whose locales differ in content format or variable schema with `bundleLocalesDiffer`."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.364.0",
|
|
41
|
+
"type": "fix",
|
|
42
|
+
"title": "Template preview counts encoding overhead and reports missing attachments",
|
|
43
|
+
"detail": "The mail size estimate counts base64 size plus a fixed per-part overhead, an attachment whose file reference is missing fails the resources_resolved check, and the document draft scope is derived once per request."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"version": "0.364.0",
|
|
47
|
+
"type": "breaking",
|
|
48
|
+
"title": "A draft no longer replaces the active template",
|
|
49
|
+
"detail": "Saving a draft (upsert-tenant default, copy-locale, import-bundle with status draft) over an active template stores it beside the active version in the new column draft instead of overwriting content, meta and status; resolveTemplate, resolveLayout, preview without content and sends keep using the active version. publish and publish-system promote an open draft (the publish gate checks the draft) and record activeVersion and activeSince; the new handlers discard-draft and discard-draft-system drop it and refuse with no_draft when none is open. find-by-id and layout-source return the working version (the open draft with status draft, else the stored one) plus active { version, since }, null when nothing is served. overview keeps status and version of the served version and adds draftVersion and activeSince. A draft over a row that is not active (new, draft or archived) behaves as before. A tenant never sees system draft content: find-by-id, layout-source, overview, copy-locale and diff-to-system apply a system draft only for a SystemAdmin and hide system rows that were never served; list leaves those rows out too. Version numbers shown for an active row are the served version (activeVersion): resolveTemplate / resolveLayout results, tenant-overrides rows and the override diff no longer show the save counter that drafts raise.",
|
|
50
|
+
"migration": "Run kumiko schema generate and apply the migration before deploying: it adds the nullable columns draft, active_version and active_since to read_template_resources. No rebuild needed."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"version": "0.364.0",
|
|
54
|
+
"type": "fix",
|
|
55
|
+
"title": "template-resolver:query:overview accepts layoutSlug to list only the mails and PDFs using that layout",
|
|
56
|
+
"detail": "The optional layoutSlug payload field keeps only documents whose layout slug matches; total counts the filtered rows. Without it the response is unchanged."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"version": "0.364.0",
|
|
60
|
+
"type": "fix",
|
|
61
|
+
"title": "Layout usage follows the effective template per notification and locale",
|
|
62
|
+
"detail": "Mail layout usage counts a notification only when it declares a template and resolves the effective row per notification and locale, so tenant overrides are reflected."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"version": "0.364.0",
|
|
66
|
+
"type": "improvement",
|
|
67
|
+
"title": "templateVariable.richText for notification variables that carry formatted HTML",
|
|
68
|
+
"detail": "Notification variables are escaped, so a value holding HTML (for example a document written in a rich text editor) showed up as literal tags in the mail. A variable declared with templateVariable.richText({ required }) is run through the document sanitizer at render time and inserted as HTML; bold, italic, lists and safe links stay, script, event handlers and javascript links are not emitted as markup. If the sanitizer rejects a value, it is rendered as escaped text and a warning with the violation codes is logged. All other variable types stay escaped."
|
|
69
|
+
},
|
|
2
70
|
{
|
|
3
71
|
"version": "0.363.0",
|
|
4
72
|
"type": "improvement",
|
|
@@ -60,6 +60,7 @@ function resolveReference(
|
|
|
60
60
|
function valueExpression(path: string, def: TemplateVariableDef | undefined): string {
|
|
61
61
|
if (def?.type === "money") return `{{money ${path}}}`;
|
|
62
62
|
if (def?.type === "date") return `{{date ${path}}}`;
|
|
63
|
+
if (def?.type === "richText") return `{{richText ${path}}}`;
|
|
63
64
|
return `{{${path}}}`;
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -123,10 +124,33 @@ function neutralizeBraces(html: string): string {
|
|
|
123
124
|
export type CompileDocumentBodyOptions = {
|
|
124
125
|
// Returns the src for an `img[data-resource]` key; undefined drops the image.
|
|
125
126
|
readonly resourceSrc?: (key: string) => string | undefined;
|
|
127
|
+
// Mail clients ignore document CSS, so a full-width logo needs an inline width cap.
|
|
128
|
+
readonly resourceImageMaxWidthPx?: number;
|
|
126
129
|
// Mail clients ignore document CSS, so mail turns data-align into an inline style.
|
|
127
130
|
readonly alignmentAsInlineStyle?: boolean;
|
|
131
|
+
// Same reason: a[data-button] only looks like a button through editor/print CSS.
|
|
132
|
+
readonly buttonAsInlineStyle?: boolean;
|
|
133
|
+
// Same reason: cell padding and table width only exist in editor/print CSS, so without
|
|
134
|
+
// inline styles a mail table renders its cells flush against each other.
|
|
135
|
+
readonly tableAsInlineStyle?: boolean;
|
|
136
|
+
// Replaces the default button look; only values from the layout appearance belong here.
|
|
137
|
+
readonly buttonInlineStyle?: string;
|
|
128
138
|
};
|
|
129
139
|
|
|
140
|
+
const MAIL_BUTTON_STYLE =
|
|
141
|
+
"display:inline-block;padding:10px 20px;border-radius:6px;background-color:#1f5468;color:#ffffff;font-weight:600;text-decoration:none";
|
|
142
|
+
|
|
143
|
+
const MAIL_TABLE_STYLE = "width:100%;border-collapse:collapse";
|
|
144
|
+
const MAIL_TABLE_CELL_STYLE = "padding:6px 12px;vertical-align:top;border-bottom:1px solid #e5e5e5";
|
|
145
|
+
const MAIL_TABLE_HEAD_STYLE = "font-size:12px;font-weight:600;color:#6b7280;text-align:left";
|
|
146
|
+
const MAIL_SMALL_STYLE = "font-size:12px;color:#6b7280";
|
|
147
|
+
|
|
148
|
+
// Appended after an existing style (data-align) so the alignment survives.
|
|
149
|
+
function appendStyle(element: ScanElement, style: string): void {
|
|
150
|
+
const existing = element.getAttribute("style");
|
|
151
|
+
element.setAttribute("style", existing === null ? style : `${existing};${style}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
130
154
|
const RESOURCE_IMAGE_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
|
131
155
|
"image/png",
|
|
132
156
|
"image/jpeg",
|
|
@@ -163,6 +187,7 @@ function rewriteAlignment(element: ScanElement): void {
|
|
|
163
187
|
function rewriteResourceImage(
|
|
164
188
|
element: ScanElement,
|
|
165
189
|
resourceSrc: CompileDocumentBodyOptions["resourceSrc"],
|
|
190
|
+
maxWidthPx: number | undefined,
|
|
166
191
|
): void {
|
|
167
192
|
const key = element.getAttribute("data-resource") ?? "";
|
|
168
193
|
const src = isDocumentResourceKey(key) ? resourceSrc?.(key) : undefined;
|
|
@@ -173,6 +198,54 @@ function rewriteResourceImage(
|
|
|
173
198
|
}
|
|
174
199
|
element.setAttribute("src", src);
|
|
175
200
|
element.removeAttribute("data-resource");
|
|
201
|
+
if (maxWidthPx !== undefined)
|
|
202
|
+
element.setAttribute("style", `max-width:${maxWidthPx}px;height:auto`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stripScriptsAndActiveContent(
|
|
206
|
+
root: ScanElement,
|
|
207
|
+
options: CompileDocumentBodyOptions,
|
|
208
|
+
): void {
|
|
209
|
+
for (const element of Array.from(root.querySelectorAll("script"))) element.remove();
|
|
210
|
+
for (const element of Array.from(root.querySelectorAll("img[data-resource]"))) {
|
|
211
|
+
rewriteResourceImage(element, options.resourceSrc, options.resourceImageMaxWidthPx);
|
|
212
|
+
}
|
|
213
|
+
for (const element of Array.from(root.querySelectorAll("*"))) stripActiveContent(element);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function applyAlignmentInlineStyles(root: ScanElement): void {
|
|
217
|
+
for (const element of Array.from(root.querySelectorAll("[data-align]"))) {
|
|
218
|
+
rewriteAlignment(element);
|
|
219
|
+
}
|
|
220
|
+
for (const element of Array.from(root.querySelectorAll("[data-size]"))) {
|
|
221
|
+
element.removeAttribute("data-size");
|
|
222
|
+
appendStyle(element, MAIL_SMALL_STYLE);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function applyTableInlineStyles(root: ScanElement): void {
|
|
227
|
+
for (const table of Array.from(root.querySelectorAll("table"))) {
|
|
228
|
+
appendStyle(table, MAIL_TABLE_STYLE);
|
|
229
|
+
}
|
|
230
|
+
for (const cell of Array.from(root.querySelectorAll("td, th"))) {
|
|
231
|
+
appendStyle(cell, MAIL_TABLE_CELL_STYLE);
|
|
232
|
+
}
|
|
233
|
+
for (const head of Array.from(root.querySelectorAll("th"))) {
|
|
234
|
+
appendStyle(head, MAIL_TABLE_HEAD_STYLE);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function rewriteLinks(
|
|
239
|
+
root: ScanElement,
|
|
240
|
+
state: CompileState,
|
|
241
|
+
options: CompileDocumentBodyOptions,
|
|
242
|
+
): void {
|
|
243
|
+
for (const element of Array.from(root.querySelectorAll("a[href]"))) {
|
|
244
|
+
rewriteLink(element, state);
|
|
245
|
+
if (options.buttonAsInlineStyle === true && element.hasAttribute("data-button")) {
|
|
246
|
+
element.setAttribute("style", options.buttonInlineStyle ?? MAIL_BUTTON_STYLE);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
176
249
|
}
|
|
177
250
|
|
|
178
251
|
// Turns a sanitized document body into Handlebars source. Rows and links are
|
|
@@ -186,22 +259,13 @@ export function compileDocumentBodyToHandlebars(
|
|
|
186
259
|
const state: CompileState = { variables, expressions: [] };
|
|
187
260
|
const scrubbed = html.replaceAll(EXPRESSION_OPEN, "").replaceAll(EXPRESSION_CLOSE, "");
|
|
188
261
|
const compiled = withParsedDocument(scrubbed, (root) => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
for (const element of Array.from(root.querySelectorAll("*"))) stripActiveContent(element);
|
|
194
|
-
if (options.alignmentAsInlineStyle === true) {
|
|
195
|
-
for (const element of Array.from(root.querySelectorAll("[data-align]"))) {
|
|
196
|
-
rewriteAlignment(element);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
262
|
+
stripScriptsAndActiveContent(root, options);
|
|
263
|
+
if (options.alignmentAsInlineStyle === true) applyAlignmentInlineStyles(root);
|
|
264
|
+
if (options.tableAsInlineStyle === true) applyTableInlineStyles(root);
|
|
199
265
|
for (const element of Array.from(root.querySelectorAll("span[data-variable]"))) {
|
|
200
266
|
rewriteVariable(element, state);
|
|
201
267
|
}
|
|
202
|
-
|
|
203
|
-
rewriteLink(element, state);
|
|
204
|
-
}
|
|
268
|
+
rewriteLinks(root, state, options);
|
|
205
269
|
for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
|
|
206
270
|
rewriteRepeatRow(element, state);
|
|
207
271
|
}
|
|
@@ -62,8 +62,18 @@ export type TemplateScope = (typeof TEMPLATE_SCOPES)[number];
|
|
|
62
62
|
export const TEMPLATE_STATUSES = ["draft", "active", "archived"] as const;
|
|
63
63
|
export type TemplateStatus = (typeof TEMPLATE_STATUSES)[number];
|
|
64
64
|
|
|
65
|
-
export
|
|
66
|
-
|
|
65
|
+
export {
|
|
66
|
+
PDF_DEFAULT_MARGIN_MM,
|
|
67
|
+
PDF_FOOTER_MARGIN_MM,
|
|
68
|
+
PDF_HEADER_MARGIN_MM,
|
|
69
|
+
PDF_ORIENTATIONS,
|
|
70
|
+
PDF_PAGE_FORMATS,
|
|
71
|
+
type PdfOrientation,
|
|
72
|
+
type PdfPageFormat,
|
|
73
|
+
} from "./pdf-constants.js";
|
|
74
|
+
|
|
75
|
+
// Colors end up inside MJML attributes, so only a strict hex is ever written there.
|
|
76
|
+
export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
67
77
|
|
|
68
78
|
export const DEFAULT_MAIL_LAYOUT_SLUG = "default";
|
|
69
79
|
|
|
@@ -9,13 +9,14 @@ 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
11
|
|
|
12
|
-
const PLACEHOLDER_HREF = /^\{\{
|
|
12
|
+
const PLACEHOLDER_HREF = /^\{\{(.+)\}\}$/;
|
|
13
13
|
const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
14
14
|
const RESOURCE_KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
15
15
|
const ALLOWED_LINK_SCHEMES = ["https://", "http://", "mailto:", "tel:"] as const;
|
|
16
16
|
|
|
17
17
|
export function isDocumentPlaceholderHref(href: string): boolean {
|
|
18
|
-
|
|
18
|
+
const name = PLACEHOLDER_HREF.exec(href)?.[1];
|
|
19
|
+
return name !== undefined && isDocumentReferenceName(name);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const UNSAFE_URL_CHARACTERS = new Set(["<", ">", '"']);
|
|
@@ -204,8 +205,6 @@ export const DocumentTableRow = TableRow.extend({
|
|
|
204
205
|
},
|
|
205
206
|
});
|
|
206
207
|
|
|
207
|
-
export const DocumentRepeatRow = DocumentTableRow;
|
|
208
|
-
|
|
209
208
|
export const DocumentTable = Table.extend({
|
|
210
209
|
renderHTML() {
|
|
211
210
|
return ["table", ["tbody", 0]];
|
|
@@ -288,6 +287,28 @@ export const DocumentTextAlign = Extension.create({
|
|
|
288
287
|
},
|
|
289
288
|
});
|
|
290
289
|
|
|
290
|
+
// Only "small" exists: the sender line above an address block. Serialized as data-size for the
|
|
291
|
+
// same reason as data-align.
|
|
292
|
+
export const DocumentTextSize = Extension.create({
|
|
293
|
+
name: "documentTextSize",
|
|
294
|
+
addGlobalAttributes() {
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
types: ["paragraph"],
|
|
298
|
+
attributes: {
|
|
299
|
+
textSize: {
|
|
300
|
+
default: null,
|
|
301
|
+
parseHTML: (element) =>
|
|
302
|
+
element.getAttribute("data-size") === "small" ? "small" : null,
|
|
303
|
+
renderHTML: (attributes) =>
|
|
304
|
+
attributes["textSize"] === "small" ? { "data-size": "small" } : {},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
291
312
|
export const documentExtensions: Extensions = [
|
|
292
313
|
StarterKit.configure({
|
|
293
314
|
blockquote: false,
|
|
@@ -320,4 +341,5 @@ export const documentExtensions: Extensions = [
|
|
|
320
341
|
DocumentDivider,
|
|
321
342
|
DocumentPageBreak,
|
|
322
343
|
DocumentTextAlign,
|
|
344
|
+
DocumentTextSize,
|
|
323
345
|
];
|
|
@@ -10,6 +10,7 @@ import { makeCollectionListQuery } from "./handlers/collection-list.query.js";
|
|
|
10
10
|
import { makeCollectionSetWrite } from "./handlers/collection-set.write.js";
|
|
11
11
|
import { copyLocaleWrite } from "./handlers/copy-locale.write.js";
|
|
12
12
|
import { diffToSystemQuery } from "./handlers/diff-to-system.query.js";
|
|
13
|
+
import { discardDraftSystemWrite, discardDraftWrite } from "./handlers/discard-draft.write.js";
|
|
13
14
|
import { exportBundleQuery } from "./handlers/export-bundle.query.js";
|
|
14
15
|
import { findByIdQuery } from "./handlers/find-by-id.query.js";
|
|
15
16
|
import { importBundleWrite } from "./handlers/import-bundle.write.js";
|
|
@@ -66,7 +67,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
66
67
|
r.describe(
|
|
67
68
|
[
|
|
68
69
|
"Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).",
|
|
69
|
-
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`.",
|
|
70
|
+
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away.",
|
|
70
71
|
"Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
|
|
71
72
|
"How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
|
|
72
73
|
'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
|
|
@@ -100,6 +101,8 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
100
101
|
archive: r.writeHandler(archiveWrite),
|
|
101
102
|
publishSystem: r.writeHandler(publishSystemWrite),
|
|
102
103
|
archiveSystem: r.writeHandler(archiveSystemWrite),
|
|
104
|
+
discardDraft: r.writeHandler(discardDraftWrite),
|
|
105
|
+
discardDraftSystem: r.writeHandler(discardDraftSystemWrite),
|
|
103
106
|
resetToSystem: r.writeHandler(resetToSystemWrite),
|
|
104
107
|
set: r.writeHandler(setWrite),
|
|
105
108
|
importBundle: r.writeHandler(importBundleWrite),
|
|
@@ -17,7 +17,8 @@ import { CONTENT_FORMATS } from "../constants.js";
|
|
|
17
17
|
import { parseLinkedResourceEntries } from "../linked-resources.js";
|
|
18
18
|
import { TemplateResolverErrors } from "../qualified-names.js";
|
|
19
19
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
20
|
-
import {
|
|
20
|
+
import { toWorkingRow } from "../template-draft.js";
|
|
21
|
+
import { attachTemplateResources, duplicateLinkedResourceFiles } from "../template-resources.js";
|
|
21
22
|
import { resolveTemplateVariables } from "../template-validation.js";
|
|
22
23
|
import { targetExistsIssue } from "./bundle-import.js";
|
|
23
24
|
import { kindSchema, localeSchema, templateSlugSchema } from "./shared.js";
|
|
@@ -50,7 +51,8 @@ async function findCopySource(
|
|
|
50
51
|
kind,
|
|
51
52
|
locale,
|
|
52
53
|
});
|
|
53
|
-
|
|
54
|
+
// An archived tenant row is not an active source: the system default applies instead.
|
|
55
|
+
if (scope === "system" || (own && own.status !== "archived")) return own;
|
|
54
56
|
return fetchOne<TemplateResourceRow>(readDb, templateResourcesTable, {
|
|
55
57
|
tenantId: SYSTEM_TENANT_ID,
|
|
56
58
|
slug,
|
|
@@ -89,7 +91,14 @@ export const copyLocaleWrite = defineWriteHandler({
|
|
|
89
91
|
if (refused !== null) return refused;
|
|
90
92
|
|
|
91
93
|
const readDb = readDbFor(ctx, scope);
|
|
92
|
-
const
|
|
94
|
+
const storedSource = await findCopySource(ctx, {
|
|
95
|
+
scope,
|
|
96
|
+
tenantId,
|
|
97
|
+
slug,
|
|
98
|
+
kind,
|
|
99
|
+
locale: fromLocale,
|
|
100
|
+
});
|
|
101
|
+
const source = storedSource && toWorkingRow(storedSource, tenantId);
|
|
93
102
|
if (!source || !isOneOf(CONTENT_FORMATS, source.contentFormat)) {
|
|
94
103
|
return writeFailure(new NotFoundError("template-resource", `${slug}/${fromLocale}`));
|
|
95
104
|
}
|
|
@@ -127,16 +136,25 @@ export const copyLocaleWrite = defineWriteHandler({
|
|
|
127
136
|
},
|
|
128
137
|
});
|
|
129
138
|
if (!written.isSuccess) return written;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
});
|
|
150
|
+
if (!resources.isSuccess) return resources;
|
|
151
|
+
const attached = await attachTemplateResources(ctx, {
|
|
152
|
+
user: event.user,
|
|
153
|
+
tenantId,
|
|
154
|
+
templateId: written.data.id,
|
|
155
|
+
resources: resources.data,
|
|
156
|
+
});
|
|
157
|
+
if (!attached.isSuccess) return attached;
|
|
140
158
|
return written;
|
|
141
159
|
},
|
|
142
160
|
});
|
|
@@ -3,29 +3,8 @@ import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framew
|
|
|
3
3
|
import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
function parseStoredMeta(raw: string | null): Readonly<Record<string, unknown>> {
|
|
9
|
-
if (raw === null) return {};
|
|
10
|
-
try {
|
|
11
|
-
const parsed: unknown = JSON.parse(raw);
|
|
12
|
-
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
13
|
-
? Object.fromEntries(Object.entries(parsed))
|
|
14
|
-
: {};
|
|
15
|
-
} catch {
|
|
16
|
-
return {};
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function diffMeta(
|
|
21
|
-
system: Readonly<Record<string, unknown>>,
|
|
22
|
-
tenant: Readonly<Record<string, unknown>>,
|
|
23
|
-
) {
|
|
24
|
-
const keys = [...new Set([...Object.keys(system), ...Object.keys(tenant)])].sort();
|
|
25
|
-
return keys
|
|
26
|
-
.filter((key) => JSON.stringify(system[key]) !== JSON.stringify(tenant[key]))
|
|
27
|
-
.map((key) => ({ key, tenant: tenant[key], system: system[key] }));
|
|
28
|
-
}
|
|
6
|
+
import { isHiddenSystemDraft } from "../template-draft.js";
|
|
7
|
+
import { buildOverrideDiff } from "./override-diff.js";
|
|
29
8
|
|
|
30
9
|
export const diffToSystemQuery = defineQueryHandler({
|
|
31
10
|
name: "diff-to-system",
|
|
@@ -46,27 +25,9 @@ export const diffToSystemQuery = defineQueryHandler({
|
|
|
46
25
|
kind: tenantRow.kind,
|
|
47
26
|
locale: tenantRow.locale,
|
|
48
27
|
});
|
|
49
|
-
if (!systemRow
|
|
28
|
+
if (!systemRow || isHiddenSystemDraft(query.user, systemRow))
|
|
29
|
+
throw new NotFoundError("template-resource", tenantRow.slug);
|
|
50
30
|
|
|
51
|
-
|
|
52
|
-
splitTemplateBlocks(systemRow.content ?? "", systemRow.contentFormat),
|
|
53
|
-
splitTemplateBlocks(tenantRow.content ?? "", tenantRow.contentFormat),
|
|
54
|
-
);
|
|
55
|
-
const meta = diffMeta(parseStoredMeta(systemRow.meta), parseStoredMeta(tenantRow.meta));
|
|
56
|
-
return {
|
|
57
|
-
tenant: {
|
|
58
|
-
templateId: String(tenantRow.id),
|
|
59
|
-
templateVersion: tenantRow.version,
|
|
60
|
-
contentFormat: tenantRow.contentFormat,
|
|
61
|
-
},
|
|
62
|
-
system: {
|
|
63
|
-
templateId: String(systemRow.id),
|
|
64
|
-
templateVersion: systemRow.version,
|
|
65
|
-
contentFormat: systemRow.contentFormat,
|
|
66
|
-
},
|
|
67
|
-
identical: meta.length === 0 && blocks.every((block) => block.change === "same"),
|
|
68
|
-
blocks,
|
|
69
|
-
meta,
|
|
70
|
-
};
|
|
31
|
+
return buildOverrideDiff(tenantRow, systemRow);
|
|
71
32
|
},
|
|
72
33
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { NotFoundError, ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { TemplateResolverErrors } from "../qualified-names.js";
|
|
5
|
+
import { executor } from "./shared.js";
|
|
6
|
+
import { loadTargetRow, type StatusTarget, writeAccessFor } from "./toggle-status.write.js";
|
|
7
|
+
|
|
8
|
+
function createDiscardDraftHandler(name: string, target: StatusTarget, description: string) {
|
|
9
|
+
return defineWriteHandler({
|
|
10
|
+
name,
|
|
11
|
+
schema: z.object({ id: z.string().min(1) }),
|
|
12
|
+
access: writeAccessFor(target),
|
|
13
|
+
...(target === "system" ? { agent: { risk: "high" as const } } : {}),
|
|
14
|
+
description,
|
|
15
|
+
handler: async (event, ctx) => {
|
|
16
|
+
const { db, scoped, row } = await loadTargetRow(ctx, target, event.user, event.payload.id);
|
|
17
|
+
if (!row) return writeFailure(new NotFoundError("template-resource", event.payload.id));
|
|
18
|
+
if (row.draft === null) {
|
|
19
|
+
return writeFailure(
|
|
20
|
+
new ValidationError({
|
|
21
|
+
fields: [
|
|
22
|
+
{
|
|
23
|
+
path: "id",
|
|
24
|
+
code: TemplateResolverErrors.noDraft,
|
|
25
|
+
i18nKey: "templateResolver.errors.noDraft",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const result = await executor.update(
|
|
32
|
+
{ id: row.id, version: row.version, changes: { draft: null } },
|
|
33
|
+
event.user,
|
|
34
|
+
db,
|
|
35
|
+
scoped ? { streamTenantId: scoped.streamTenantId } : undefined,
|
|
36
|
+
);
|
|
37
|
+
if (!result.isSuccess) return result;
|
|
38
|
+
return { isSuccess: true as const, data: { id: String(row.id), discarded: true } };
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const discardDraftWrite = createDiscardDraftHandler(
|
|
44
|
+
"discard-draft",
|
|
45
|
+
"own",
|
|
46
|
+
"Throws away the open draft of one template resource of the caller's tenant so the editor shows the active version again; the served version is untouched. Refused with no_draft when there is no draft.",
|
|
47
|
+
);
|
|
48
|
+
export const discardDraftSystemWrite = createDiscardDraftHandler(
|
|
49
|
+
"discard-draft-system",
|
|
50
|
+
"system",
|
|
51
|
+
"Throws away the open draft of one system-default template; the served version stays. SystemAdmin only, refused with no_draft when there is no draft.",
|
|
52
|
+
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
3
|
import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { NotFoundError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import * as z from "zod";
|
|
5
6
|
import {
|
|
6
7
|
isFileAttachmentEntry,
|
|
@@ -8,12 +9,63 @@ import {
|
|
|
8
9
|
type LinkedResourceEntry,
|
|
9
10
|
parseLinkedResourceEntries,
|
|
10
11
|
} from "../linked-resources.js";
|
|
12
|
+
import { TemplateResolverErrors } from "../qualified-names.js";
|
|
11
13
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
12
14
|
import type { TemplateBundle } from "../template-bundle.js";
|
|
13
15
|
import { readTemplateResource } from "../template-resources.js";
|
|
14
16
|
import { kindSchema, templateSlugSchema } from "./shared.js";
|
|
15
17
|
import { parseStoredJson, storedRecord } from "./template-gates.js";
|
|
16
18
|
|
|
19
|
+
// The files live in the system tenant's key space; readTemplateResource only
|
|
20
|
+
// serves the caller's own tenant, so anyone else would end in a misleading NotFound.
|
|
21
|
+
function assertSystemFilesReadable(
|
|
22
|
+
scope: "tenant" | "system",
|
|
23
|
+
entries: Iterable<LinkedResourceEntry>,
|
|
24
|
+
callerTenantId: string,
|
|
25
|
+
): void {
|
|
26
|
+
// skip: tenant exports read the caller's own files.
|
|
27
|
+
if (scope === "tenant") return;
|
|
28
|
+
// skip: the system tenant reads its own key space.
|
|
29
|
+
if (callerTenantId === SYSTEM_TENANT_ID) return;
|
|
30
|
+
const hasFileResources = [...entries].some((entry) => !isPdfAttachmentLink(entry));
|
|
31
|
+
// skip: PDF attachment links carry no file to read.
|
|
32
|
+
if (!hasFileResources) return;
|
|
33
|
+
throw new ValidationError({
|
|
34
|
+
fields: [
|
|
35
|
+
{
|
|
36
|
+
path: "scope",
|
|
37
|
+
code: "system_resources_need_system_tenant",
|
|
38
|
+
i18nKey: "templateResolver.errors.template.systemResourcesNeedSystemTenant",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A bundle carries one contentFormat and one variableSchema for all locales; importing it would
|
|
45
|
+
// relabel the rows that differ from the first.
|
|
46
|
+
function assertLocalesShareFormat(rows: readonly TemplateResourceRow[]): void {
|
|
47
|
+
const [first, ...rest] = rows;
|
|
48
|
+
// skip: a bundle without rows has nothing to compare
|
|
49
|
+
if (first === undefined) return;
|
|
50
|
+
const firstSchema = storedRecord(first.variableSchema);
|
|
51
|
+
const differing = rest.find(
|
|
52
|
+
(row) =>
|
|
53
|
+
row.contentFormat !== first.contentFormat ||
|
|
54
|
+
!isDeepStrictEqual(storedRecord(row.variableSchema), firstSchema),
|
|
55
|
+
);
|
|
56
|
+
// skip: all locales agree
|
|
57
|
+
if (differing === undefined) return;
|
|
58
|
+
throw new ValidationError({
|
|
59
|
+
fields: [
|
|
60
|
+
{
|
|
61
|
+
path: "slug",
|
|
62
|
+
code: TemplateResolverErrors.bundleLocalesDiffer,
|
|
63
|
+
i18nKey: "templateResolver.errors.template.bundleLocalesDiffer",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
17
69
|
export const exportBundleQuery = defineQueryHandler({
|
|
18
70
|
name: "export-bundle",
|
|
19
71
|
schema: z.object({
|
|
@@ -38,6 +90,7 @@ export const exportBundleQuery = defineQueryHandler({
|
|
|
38
90
|
.sort((a, b) => a.locale.localeCompare(b.locale));
|
|
39
91
|
const first = rows[0];
|
|
40
92
|
if (!first) throw new NotFoundError("template-resource", slug);
|
|
93
|
+
assertLocalesShareFormat(rows);
|
|
41
94
|
|
|
42
95
|
const locales: Record<string, { body: string; meta?: unknown }> = {};
|
|
43
96
|
const entryByKey = new Map<string, LinkedResourceEntry>();
|
|
@@ -49,6 +102,8 @@ export const exportBundleQuery = defineQueryHandler({
|
|
|
49
102
|
}
|
|
50
103
|
}
|
|
51
104
|
|
|
105
|
+
assertSystemFilesReadable(scope, entryByKey.values(), query.user.tenantId);
|
|
106
|
+
|
|
52
107
|
const resources: TemplateBundle["resources"] = {};
|
|
53
108
|
const pdfAttachments: TemplateBundle["pdfAttachments"] = {};
|
|
54
109
|
for (const [key, entry] of entryByKey) {
|
|
@@ -2,6 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { type TemplateResourceRow, templateResourcesTable } from "../table.js";
|
|
5
|
+
import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.js";
|
|
5
6
|
|
|
6
7
|
// Admin-Lookup für UI-Edit-Flow. Returnt das raw Template inkl. draft/
|
|
7
8
|
// archived Status. Tenant-Isolation: User sieht nur Templates des
|
|
@@ -13,12 +14,13 @@ export const findByIdQuery = defineQueryHandler({
|
|
|
13
14
|
schema: z.object({ id: z.string().min(1) }),
|
|
14
15
|
access: { roles: ["TenantAdmin", "SystemAdmin", "User"] },
|
|
15
16
|
description:
|
|
16
|
-
"Reads one template resource by row id with
|
|
17
|
+
"Reads the version of one template resource an editor works on, by row id: the open draft if there is one (status draft), else the stored version, with content, variable schema and scope, but only when it belongs to the caller's tenant or is a served system default (a never-served system draft is visible to a SystemAdmin only); `active` is the version number and since-time the resolver serves, null when none is served. Use it to load a template into the admin editor.",
|
|
17
18
|
handler: async (query, ctx) => {
|
|
18
|
-
const
|
|
19
|
+
const stored = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
|
|
19
20
|
id: query.payload.id,
|
|
20
21
|
});
|
|
21
|
-
if (!
|
|
22
|
+
if (!stored || isHiddenSystemDraft(query.user, stored)) return null;
|
|
23
|
+
const row = toWorkingRow(stored, draftViewerTenantId(query.user, stored));
|
|
22
24
|
const isOwnTenant = row.tenantId === query.user.tenantId;
|
|
23
25
|
const isSystemTemplate = row.scope === "system";
|
|
24
26
|
if (!isOwnTenant && !isSystemTemplate) return null;
|
|
@@ -39,6 +41,7 @@ export const findByIdQuery = defineQueryHandler({
|
|
|
39
41
|
parentTemplateId: row.parentTemplateId,
|
|
40
42
|
status: row.status,
|
|
41
43
|
modifiedAt: row.modifiedAt,
|
|
44
|
+
active: row.active,
|
|
42
45
|
};
|
|
43
46
|
},
|
|
44
47
|
});
|