@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { CONTENT_FORMATS, type ContentFormat } from "./constants.js";
|
|
4
|
+
import type { TemplateResourceRow } from "./table.js";
|
|
5
|
+
|
|
6
|
+
const templateDraftSchema = z.object({
|
|
7
|
+
content: z.string().nullable(),
|
|
8
|
+
contentFormat: z.enum(CONTENT_FORMATS),
|
|
9
|
+
variableSchema: z.string(),
|
|
10
|
+
meta: z.string().nullable(),
|
|
11
|
+
title: z.string().nullable(),
|
|
12
|
+
folder: z.string().nullable(),
|
|
13
|
+
parentTemplateId: z.string().nullable(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// An unpublished edit beside the active version; variableSchema and meta stay
|
|
17
|
+
// JSON strings, exactly like the columns they replace on publish.
|
|
18
|
+
export type TemplateDraft = {
|
|
19
|
+
readonly content: string | null;
|
|
20
|
+
readonly contentFormat: ContentFormat;
|
|
21
|
+
readonly variableSchema: string;
|
|
22
|
+
readonly meta: string | null;
|
|
23
|
+
readonly title: string | null;
|
|
24
|
+
readonly folder: string | null;
|
|
25
|
+
readonly parentTemplateId: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function parseTemplateDraft(raw: string | null): TemplateDraft | null {
|
|
29
|
+
if (raw === null) return null;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = templateDraftSchema.safeParse(JSON.parse(raw));
|
|
32
|
+
return parsed.success ? parsed.data : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function serializeTemplateDraft(draft: TemplateDraft): string {
|
|
39
|
+
return JSON.stringify(draft);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function activeVersionOf(row: TemplateResourceRow): number {
|
|
43
|
+
return row.activeVersion ?? row.version;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `version` also counts draft saves, so only a row without a served version shows it as its number.
|
|
47
|
+
export function servedVersionOf(row: TemplateResourceRow): number {
|
|
48
|
+
return row.status === "active" ? activeVersionOf(row) : row.version;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activeSinceOf(row: TemplateResourceRow): Temporal.Instant {
|
|
52
|
+
return row.activeSince ?? row.modifiedAt ?? row.insertedAt;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ActiveInfo = { readonly version: number; readonly since: Temporal.Instant };
|
|
56
|
+
|
|
57
|
+
export type WorkingRow = TemplateResourceRow & { readonly active: ActiveInfo | null };
|
|
58
|
+
|
|
59
|
+
// A SystemAdmin edits system rows, so for them those rows count as their own.
|
|
60
|
+
function isSystemAdmin(user: { readonly roles: readonly string[] }): boolean {
|
|
61
|
+
return user.roles.includes("SystemAdmin");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A system row that was never served keeps its draft in the main fields, so only a SystemAdmin may read it.
|
|
65
|
+
export function isHiddenSystemDraft(
|
|
66
|
+
user: { readonly roles: readonly string[] },
|
|
67
|
+
row: Pick<TemplateResourceRow, "scope" | "status">,
|
|
68
|
+
): boolean {
|
|
69
|
+
return row.scope === "system" && row.status === "draft" && !isSystemAdmin(user);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function draftViewerTenantId(
|
|
73
|
+
user: { readonly tenantId: string; readonly roles: readonly string[] },
|
|
74
|
+
row: TemplateResourceRow,
|
|
75
|
+
): string {
|
|
76
|
+
return row.scope === "system" && isSystemAdmin(user) ? row.tenantId : user.tenantId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The version an editor works on: the open draft if there is one, else the stored row.
|
|
80
|
+
// A draft is only shown to the tenant that owns the row, never through a system row.
|
|
81
|
+
export function toWorkingRow(row: TemplateResourceRow, viewerTenantId: string): WorkingRow {
|
|
82
|
+
const active =
|
|
83
|
+
row.status === "active" ? { version: activeVersionOf(row), since: activeSinceOf(row) } : null;
|
|
84
|
+
const draft = row.tenantId === viewerTenantId ? parseTemplateDraft(row.draft) : null;
|
|
85
|
+
// Saves and discards of drafts raise row.version; an editor without a draft shows the served number.
|
|
86
|
+
if (draft === null) return { ...row, version: active?.version ?? row.version, active };
|
|
87
|
+
return { ...row, ...draft, status: "draft", active };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Event changes that turn a row into a served one. Every value is explicit so a
|
|
91
|
+
// rebuild reproduces the row without deriving anything.
|
|
92
|
+
export function servedStampChanges(row: { readonly version: number } | null) {
|
|
93
|
+
return {
|
|
94
|
+
activeVersion: row === null ? 1 : row.version + 1,
|
|
95
|
+
activeSince: Temporal.Now.instant(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function activeStampChanges(row: { readonly version: number } | null) {
|
|
100
|
+
return { status: "active", ...servedStampChanges(row) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Publishing also consumes the open draft.
|
|
104
|
+
export function activationChanges(row: TemplateResourceRow | null) {
|
|
105
|
+
return { ...activeStampChanges(row), draft: null };
|
|
106
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
HEX_COLOR_PATTERN,
|
|
4
|
+
PDF_ORIENTATIONS,
|
|
5
|
+
PDF_PAGE_FORMATS,
|
|
6
|
+
type TemplateKind,
|
|
7
|
+
} from "./constants.js";
|
|
3
8
|
|
|
4
9
|
// One or more kebab segments joined by ":" — notification templates are seeded
|
|
5
10
|
// under their qualified name `feature:notify:name`.
|
|
@@ -22,21 +27,26 @@ const singleLine = (max: number) =>
|
|
|
22
27
|
.max(max)
|
|
23
28
|
.refine((value) => !/[\r\n]/.test(value), "must not contain line breaks");
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
sender: z
|
|
32
|
-
.string()
|
|
33
|
-
.max(60)
|
|
34
|
-
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "sender must be a kebab-case key")
|
|
35
|
-
.optional(),
|
|
36
|
-
})
|
|
37
|
-
.strict();
|
|
30
|
+
// Rows saved before the sender key was dropped still carry it; discard it on read so they stay editable.
|
|
31
|
+
function withoutLegacySender(raw: unknown): unknown {
|
|
32
|
+
if (typeof raw !== "object" || raw === null || !("sender" in raw)) return raw;
|
|
33
|
+
const { sender: _legacySender, ...rest } = raw;
|
|
34
|
+
return rest;
|
|
35
|
+
}
|
|
38
36
|
|
|
39
|
-
const
|
|
37
|
+
const mailMetaSchema = z.preprocess(
|
|
38
|
+
withoutLegacySender,
|
|
39
|
+
z
|
|
40
|
+
.object({
|
|
41
|
+
subject: singleLine(200).min(1),
|
|
42
|
+
previewText: singleLine(200).optional(),
|
|
43
|
+
layoutSlug: templateSlugSchema.optional(),
|
|
44
|
+
category: z.enum(MAIL_CATEGORIES).optional(),
|
|
45
|
+
senderName: singleLine(80).min(1).optional(),
|
|
46
|
+
replyTo: singleLine(254).pipe(z.email()).optional(),
|
|
47
|
+
})
|
|
48
|
+
.strict(),
|
|
49
|
+
);
|
|
40
50
|
|
|
41
51
|
const marginMm = z.number().finite().min(0).max(100).optional();
|
|
42
52
|
|
|
@@ -71,11 +81,26 @@ const pdfMetaSchema = z
|
|
|
71
81
|
})
|
|
72
82
|
.strict();
|
|
73
83
|
|
|
74
|
-
const
|
|
84
|
+
const MAIL_LAYOUT_FONTS = ["sans", "serif"] as const;
|
|
85
|
+
const MAIL_LAYOUT_ACCENT_LINES = ["branding", "none"] as const;
|
|
86
|
+
const MAIL_LAYOUT_BUTTON_STYLES = ["filled-rounded", "filled-square", "outline"] as const;
|
|
87
|
+
|
|
88
|
+
const mailLayoutMetaSchema = z
|
|
89
|
+
.object({
|
|
90
|
+
width: z.union([z.literal(560), z.literal(600), z.literal(640)]).optional(),
|
|
91
|
+
font: z.enum(MAIL_LAYOUT_FONTS).optional(),
|
|
92
|
+
outerBackground: z.string().regex(HEX_COLOR_PATTERN, "must be a #rrggbb color").optional(),
|
|
93
|
+
accentLine: z.enum(MAIL_LAYOUT_ACCENT_LINES).optional(),
|
|
94
|
+
buttonStyle: z.enum(MAIL_LAYOUT_BUTTON_STYLES).optional(),
|
|
95
|
+
})
|
|
96
|
+
.strict();
|
|
97
|
+
|
|
98
|
+
const pdfLayoutMetaSchema = z.object({}).strict();
|
|
75
99
|
|
|
76
100
|
export type MailTemplateMeta = z.infer<typeof mailMetaSchema>;
|
|
77
101
|
export type PdfTemplateMeta = z.infer<typeof pdfMetaSchema>;
|
|
78
|
-
export type
|
|
102
|
+
export type MailLayoutMeta = z.infer<typeof mailLayoutMetaSchema>;
|
|
103
|
+
export type LayoutTemplateMeta = MailLayoutMeta | z.infer<typeof pdfLayoutMetaSchema>;
|
|
79
104
|
export type TemplateMeta = MailTemplateMeta | PdfTemplateMeta | LayoutTemplateMeta;
|
|
80
105
|
|
|
81
106
|
export function isMailTemplateMeta(meta: TemplateMeta | null): meta is MailTemplateMeta {
|
|
@@ -87,7 +112,29 @@ export function readPdfTemplateMeta(meta: TemplateMeta | null): PdfTemplateMeta
|
|
|
87
112
|
return parsed.success ? parsed.data : null;
|
|
88
113
|
}
|
|
89
114
|
|
|
90
|
-
export
|
|
115
|
+
export function readMailLayoutMeta(meta: unknown): MailLayoutMeta {
|
|
116
|
+
const parsed = mailLayoutMetaSchema.safeParse(meta ?? {});
|
|
117
|
+
return parsed.success ? parsed.data : {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TemplateMetaIssue = {
|
|
121
|
+
readonly path: string;
|
|
122
|
+
/** The setting the issue is about (`subject`), `meta` when it concerns the object as a whole. */
|
|
123
|
+
readonly field: string;
|
|
124
|
+
/** The value is absent or empty, as opposed to present and wrong. */
|
|
125
|
+
readonly missing: boolean;
|
|
126
|
+
/** Parser wording; for logs and tests, never for the UI. */
|
|
127
|
+
readonly message: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function valueAt(raw: unknown, path: readonly PropertyKey[]): unknown {
|
|
131
|
+
let current: unknown = raw;
|
|
132
|
+
for (const key of path) {
|
|
133
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
134
|
+
current = Object.entries(current).find(([name]) => name === String(key))?.[1];
|
|
135
|
+
}
|
|
136
|
+
return current;
|
|
137
|
+
}
|
|
91
138
|
|
|
92
139
|
export type TemplateMetaResult =
|
|
93
140
|
| { readonly ok: true; readonly meta: TemplateMeta | null }
|
|
@@ -100,8 +147,9 @@ function schemaForKind(kind: TemplateKind) {
|
|
|
100
147
|
case "document-pdf":
|
|
101
148
|
return pdfMetaSchema;
|
|
102
149
|
case "mail-layout":
|
|
150
|
+
return mailLayoutMetaSchema;
|
|
103
151
|
case "pdf-layout":
|
|
104
|
-
return
|
|
152
|
+
return pdfLayoutMetaSchema;
|
|
105
153
|
default:
|
|
106
154
|
return null;
|
|
107
155
|
}
|
|
@@ -114,16 +162,31 @@ export function parseTemplateMeta(kind: TemplateKind, raw: unknown): TemplateMet
|
|
|
114
162
|
if (schema === null) {
|
|
115
163
|
return raw === undefined || raw === null
|
|
116
164
|
? { ok: true, meta: null }
|
|
117
|
-
: {
|
|
165
|
+
: {
|
|
166
|
+
ok: false,
|
|
167
|
+
issues: [
|
|
168
|
+
{
|
|
169
|
+
path: "meta",
|
|
170
|
+
field: "meta",
|
|
171
|
+
missing: false,
|
|
172
|
+
message: `kind ${kind} does not take meta`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
118
176
|
}
|
|
119
177
|
if ((raw === undefined || raw === null) && kind !== "mail") return { ok: true, meta: null };
|
|
120
178
|
const parsed = schema.safeParse(raw ?? undefined);
|
|
121
179
|
if (parsed.success) return { ok: true, meta: parsed.data };
|
|
122
180
|
return {
|
|
123
181
|
ok: false,
|
|
124
|
-
issues: parsed.error.issues.map((issue) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
182
|
+
issues: parsed.error.issues.map((issue) => {
|
|
183
|
+
const value = valueAt(raw, issue.path);
|
|
184
|
+
return {
|
|
185
|
+
path: ["meta", ...issue.path.map(String)].join("."),
|
|
186
|
+
field: issue.path.length === 0 ? "meta" : String(issue.path[0]),
|
|
187
|
+
missing: value === undefined || value === "",
|
|
188
|
+
message: issue.message,
|
|
189
|
+
};
|
|
190
|
+
}),
|
|
128
191
|
};
|
|
129
192
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ValidationError,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
15
15
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-headless";
|
|
16
17
|
import { createEmailAttachmentReader, createTemplateMailRenderer } from "../delivery/index.js";
|
|
17
18
|
import { fileContextForTenant } from "../file-foundation/index.js";
|
|
18
19
|
import {
|
|
@@ -24,10 +25,20 @@ import {
|
|
|
24
25
|
} from "../renderer-foundation/index.js";
|
|
25
26
|
import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResource } from "./api.js";
|
|
26
27
|
import { collectDocumentResourceKeys } from "./compile-document-body.js";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_MAIL_LAYOUT_SLUG,
|
|
30
|
+
MAIL_PREVIEW_MAX_BYTES,
|
|
31
|
+
SYSTEM_TENANT_ID,
|
|
32
|
+
type TemplateScope,
|
|
33
|
+
} from "./constants.js";
|
|
28
34
|
import { parseStoredJson } from "./handlers/template-gates.js";
|
|
29
35
|
import { validateTemplateDraft } from "./handlers/validate.query.js";
|
|
30
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
type LayoutRegions,
|
|
38
|
+
readLayoutRegions,
|
|
39
|
+
sanitizeLayoutHtml,
|
|
40
|
+
type TemplateWriteScope,
|
|
41
|
+
} from "./layout-regions.js";
|
|
31
42
|
import { type LinkedResourceEntry, parseLinkedResourceEntries } from "./linked-resources.js";
|
|
32
43
|
import { buildDisplayHtml } from "./preview-display-html.js";
|
|
33
44
|
import { TemplateResolverErrors } from "./qualified-names.js";
|
|
@@ -42,7 +53,7 @@ import {
|
|
|
42
53
|
|
|
43
54
|
const SAMPLE_LINK = "https://example.com";
|
|
44
55
|
const SAMPLE_DATE = "2026-01-15";
|
|
45
|
-
const SAMPLE_LIST_ROWS =
|
|
56
|
+
const SAMPLE_LIST_ROWS = 3;
|
|
46
57
|
const DEFAULT_PDF_LAYOUT_SLUG = "default";
|
|
47
58
|
|
|
48
59
|
function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
|
|
@@ -50,6 +61,8 @@ function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
|
|
|
50
61
|
switch (def.type) {
|
|
51
62
|
case "text":
|
|
52
63
|
return `[${def.label ?? name}]`;
|
|
64
|
+
case "richText":
|
|
65
|
+
return `<p>[${escapeHtml(def.label ?? name)}]</p>`;
|
|
53
66
|
case "link":
|
|
54
67
|
return SAMPLE_LINK;
|
|
55
68
|
case "money":
|
|
@@ -141,6 +154,12 @@ export type TemplatePreview = {
|
|
|
141
154
|
readonly text: string | null;
|
|
142
155
|
readonly attachments: readonly PreviewAttachment[];
|
|
143
156
|
readonly layout: PreviewLayout | null;
|
|
157
|
+
/** Mail only: which layout version the html was rendered through. */
|
|
158
|
+
readonly mailLayout?: {
|
|
159
|
+
readonly slug: string;
|
|
160
|
+
readonly scope: TemplateScope;
|
|
161
|
+
readonly version: number;
|
|
162
|
+
} | null;
|
|
144
163
|
readonly sampleValues: Readonly<Record<string, unknown>>;
|
|
145
164
|
readonly checks: readonly PreviewCheck[];
|
|
146
165
|
readonly validation: ValidationResponse;
|
|
@@ -152,6 +171,7 @@ export type TemplatePreviewOutcome =
|
|
|
152
171
|
readonly preview: TemplatePreview;
|
|
153
172
|
/** What the send path needs to attach; kept out of the response shown to the editor. */
|
|
154
173
|
readonly mailAttachments: readonly MailAttachmentRef[];
|
|
174
|
+
readonly sender: { readonly fromName?: string; readonly replyTo?: string };
|
|
155
175
|
}
|
|
156
176
|
| { readonly ok: false; readonly error: KumikoError };
|
|
157
177
|
|
|
@@ -180,7 +200,7 @@ async function loadStoredDraft(
|
|
|
180
200
|
async function describeAttachments(
|
|
181
201
|
ctx: HandlerContext,
|
|
182
202
|
refs: readonly MailAttachmentRef[],
|
|
183
|
-
): Promise<PreviewAttachment[]> {
|
|
203
|
+
): Promise<{ readonly attachments: PreviewAttachment[]; readonly missing: string[] }> {
|
|
184
204
|
const fileRefIds = refs.flatMap((ref) => (ref.kind === "rendered-pdf" ? [] : [ref.fileRefId]));
|
|
185
205
|
const rows =
|
|
186
206
|
fileRefIds.length === 0
|
|
@@ -191,7 +211,14 @@ async function describeAttachments(
|
|
|
191
211
|
const sizeById = new Map(rows.map((row) => [row.id, Number(row.size)]));
|
|
192
212
|
// An inline image is named by its content id because the stored file name is personal data and
|
|
193
213
|
// may be ciphertext; a file attachment carries the name the admin gave it.
|
|
194
|
-
|
|
214
|
+
const attachments = refs.map((ref) => previewAttachmentOf(ref, sizeById));
|
|
215
|
+
// A fileRef without a row has no known size: it is reported, not counted as 0 bytes.
|
|
216
|
+
const missing = refs.flatMap((ref, index) =>
|
|
217
|
+
ref.kind !== "rendered-pdf" && !sizeById.has(ref.fileRefId)
|
|
218
|
+
? [attachments[index]?.name ?? ""]
|
|
219
|
+
: [],
|
|
220
|
+
);
|
|
221
|
+
return { attachments, missing };
|
|
195
222
|
}
|
|
196
223
|
|
|
197
224
|
function previewAttachmentOf(
|
|
@@ -213,8 +240,28 @@ function previewAttachmentOf(
|
|
|
213
240
|
}
|
|
214
241
|
}
|
|
215
242
|
|
|
243
|
+
// Part headers and multipart boundary lines around one attachment in the MIME message.
|
|
244
|
+
export const MIME_PART_OVERHEAD_BYTES = 512;
|
|
245
|
+
|
|
246
|
+
// The provider cap applies to the encoded message: attachments travel base64-encoded.
|
|
247
|
+
export function encodedAttachmentBytes(rawBytes: number): number {
|
|
248
|
+
return Math.ceil(rawBytes / 3) * 4 + MIME_PART_OVERHEAD_BYTES;
|
|
249
|
+
}
|
|
250
|
+
|
|
216
251
|
const attachmentSizeOf = (attachment: PreviewAttachment): number =>
|
|
217
|
-
attachment.kind === "rendered-pdf" ? 0 : attachment.sizeBytes;
|
|
252
|
+
attachment.kind === "rendered-pdf" ? 0 : encodedAttachmentBytes(attachment.sizeBytes);
|
|
253
|
+
|
|
254
|
+
export function estimateMailBytes(args: {
|
|
255
|
+
readonly html: string;
|
|
256
|
+
readonly text: string;
|
|
257
|
+
readonly attachments: readonly PreviewAttachment[];
|
|
258
|
+
}): number {
|
|
259
|
+
return (
|
|
260
|
+
utf8Bytes(args.html) +
|
|
261
|
+
utf8Bytes(args.text) +
|
|
262
|
+
args.attachments.reduce((sum, attachment) => sum + attachmentSizeOf(attachment), 0)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
218
265
|
|
|
219
266
|
// The row an editor is working on, whatever its status: the stored template only resolves when
|
|
220
267
|
// active, so a draft would otherwise lose the images and attachments uploaded to it.
|
|
@@ -328,6 +375,7 @@ async function renderDocumentPreview(
|
|
|
328
375
|
return {
|
|
329
376
|
ok: true,
|
|
330
377
|
mailAttachments: [],
|
|
378
|
+
sender: {},
|
|
331
379
|
preview: {
|
|
332
380
|
kind: "document-pdf",
|
|
333
381
|
subject: null,
|
|
@@ -362,7 +410,8 @@ async function renderDocumentPreview(
|
|
|
362
410
|
registry: ctx.registry,
|
|
363
411
|
tenantId: user.tenantId,
|
|
364
412
|
...(ctx.config !== undefined && { config: ctx.config }),
|
|
365
|
-
filesForTenant: (fileTenantId) =>
|
|
413
|
+
filesForTenant: (fileTenantId) =>
|
|
414
|
+
fileContextForTenant(ctx, ctx.user.tenantId, fileTenantId),
|
|
366
415
|
},
|
|
367
416
|
);
|
|
368
417
|
} catch (e) {
|
|
@@ -372,6 +421,7 @@ async function renderDocumentPreview(
|
|
|
372
421
|
return {
|
|
373
422
|
ok: true,
|
|
374
423
|
mailAttachments: [],
|
|
424
|
+
sender: {},
|
|
375
425
|
preview: {
|
|
376
426
|
kind: "document-pdf",
|
|
377
427
|
subject: null,
|
|
@@ -409,11 +459,12 @@ export type DocumentDraftOutcome =
|
|
|
409
459
|
}
|
|
410
460
|
| { readonly ok: false; readonly error: KumikoError };
|
|
411
461
|
|
|
412
|
-
//
|
|
413
|
-
// and a failing draft is rejected with the same problems as a save.
|
|
462
|
+
// The caller derives `scope` once from the role (SystemAdmin edits the system row), so the write
|
|
463
|
+
// gates and the print see the same row: a failing draft is rejected with the same problems as a save.
|
|
414
464
|
export async function evaluateDocumentDraft(
|
|
415
465
|
ctx: HandlerContext,
|
|
416
466
|
user: SessionUser,
|
|
467
|
+
scope: TemplateWriteScope,
|
|
417
468
|
draft: {
|
|
418
469
|
readonly slug: string;
|
|
419
470
|
readonly locale: string;
|
|
@@ -430,7 +481,7 @@ export async function evaluateDocumentDraft(
|
|
|
430
481
|
meta: draft.meta,
|
|
431
482
|
variableSchema: draft.variableSchema,
|
|
432
483
|
locale: draft.locale,
|
|
433
|
-
scope
|
|
484
|
+
scope,
|
|
434
485
|
});
|
|
435
486
|
if (!evaluation.ok) {
|
|
436
487
|
return { ok: false, error: new ValidationError({ fields: [...evaluation.issues] }) };
|
|
@@ -445,51 +496,39 @@ export async function evaluateDocumentDraft(
|
|
|
445
496
|
};
|
|
446
497
|
}
|
|
447
498
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
499
|
+
type DraftSource = {
|
|
500
|
+
readonly content: string;
|
|
501
|
+
readonly meta: unknown;
|
|
502
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// The posted draft when it carries content, else the stored template; null when none is stored.
|
|
506
|
+
async function resolveDraftSource(
|
|
451
507
|
ctx: HandlerContext,
|
|
452
508
|
user: SessionUser,
|
|
453
509
|
input: TemplatePreviewInput,
|
|
454
|
-
): Promise<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
let draftVariableSchema = input.variableSchema;
|
|
458
|
-
if (draftContent === undefined) {
|
|
459
|
-
const stored = await loadStoredDraft(ctx, user, input);
|
|
460
|
-
if (stored === null) return failed(new NotFoundError("template-resource", input.slug));
|
|
461
|
-
draftContent = stored.content;
|
|
462
|
-
draftMeta = stored.meta;
|
|
463
|
-
draftVariableSchema = stored.variableSchema;
|
|
510
|
+
): Promise<DraftSource | null> {
|
|
511
|
+
if (input.content !== undefined) {
|
|
512
|
+
return { content: input.content, meta: input.meta, variableSchema: input.variableSchema };
|
|
464
513
|
}
|
|
514
|
+
const stored = await loadStoredDraft(ctx, user, input);
|
|
515
|
+
if (stored === null) return null;
|
|
516
|
+
return { content: stored.content, meta: stored.meta, variableSchema: stored.variableSchema };
|
|
517
|
+
}
|
|
465
518
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
519
|
+
async function renderMailPreview(
|
|
520
|
+
ctx: HandlerContext,
|
|
521
|
+
user: SessionUser,
|
|
522
|
+
args: DocumentPreviewArgs,
|
|
523
|
+
): Promise<TemplatePreviewOutcome> {
|
|
524
|
+
const {
|
|
525
|
+
input,
|
|
526
|
+
content,
|
|
527
|
+
meta,
|
|
528
|
+
validation,
|
|
529
|
+
sampleValues,
|
|
472
530
|
variableSchema: draftVariableSchema,
|
|
473
|
-
|
|
474
|
-
scope: "tenant",
|
|
475
|
-
});
|
|
476
|
-
if (!evaluation.ok) return failed(new ValidationError({ fields: [...evaluation.issues] }));
|
|
477
|
-
|
|
478
|
-
const content = evaluation.content ?? "";
|
|
479
|
-
const meta = parseTemplateMeta(input.kind, parseStoredJson(evaluation.metaJson));
|
|
480
|
-
const sampleValues = sampleValuesFor(validation.variables);
|
|
481
|
-
|
|
482
|
-
if (input.kind === "document-pdf") {
|
|
483
|
-
return renderDocumentPreview(ctx, user, {
|
|
484
|
-
input,
|
|
485
|
-
content,
|
|
486
|
-
meta,
|
|
487
|
-
variableSchema: draftVariableSchema,
|
|
488
|
-
sampleValues,
|
|
489
|
-
validation,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
|
|
531
|
+
} = args;
|
|
493
532
|
if (!meta.ok || !isMailTemplateMeta(meta.meta)) {
|
|
494
533
|
return failed(
|
|
495
534
|
new UnprocessableError(TemplateResolverErrors.previewRenderFailed, {
|
|
@@ -525,23 +564,27 @@ export async function renderTemplatePreview(
|
|
|
525
564
|
|
|
526
565
|
const mailAttachments = rendered.attachments ?? [];
|
|
527
566
|
const inline = mailAttachments.filter((ref) => ref.kind === "inline");
|
|
528
|
-
const attachments = await describeAttachments(
|
|
567
|
+
const { attachments, missing: missingAttachments } = await describeAttachments(
|
|
568
|
+
ctx,
|
|
569
|
+
mailAttachments,
|
|
570
|
+
);
|
|
529
571
|
const displayHtml = await buildDisplayHtml(
|
|
530
572
|
rendered.html,
|
|
531
573
|
inline,
|
|
532
574
|
createEmailAttachmentReader(ctx.db, ctx.registry, user.tenantId, ctx.config, (tenantId) =>
|
|
533
|
-
fileContextForTenant(ctx, tenantId),
|
|
575
|
+
fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
|
|
534
576
|
),
|
|
535
577
|
);
|
|
536
578
|
const resolvedCids = new Set(inline.map((ref) => ref.cid));
|
|
537
579
|
const unresolved = collectDocumentResourceKeys(content).filter((key) => !resolvedCids.has(key));
|
|
538
|
-
const totalBytes =
|
|
539
|
-
utf8Bytes(rendered.html) +
|
|
540
|
-
utf8Bytes(rendered.text) +
|
|
541
|
-
attachments.reduce((sum, attachment) => sum + attachmentSizeOf(attachment), 0);
|
|
580
|
+
const totalBytes = estimateMailBytes({ html: rendered.html, text: rendered.text, attachments });
|
|
542
581
|
return {
|
|
543
582
|
ok: true,
|
|
544
583
|
mailAttachments,
|
|
584
|
+
sender: {
|
|
585
|
+
...(rendered.fromName !== undefined && { fromName: rendered.fromName }),
|
|
586
|
+
...(rendered.replyTo !== undefined && { replyTo: rendered.replyTo }),
|
|
587
|
+
},
|
|
545
588
|
preview: {
|
|
546
589
|
kind: "mail",
|
|
547
590
|
subject: rendered.subject,
|
|
@@ -551,9 +594,17 @@ export async function renderTemplatePreview(
|
|
|
551
594
|
text: rendered.text,
|
|
552
595
|
attachments,
|
|
553
596
|
layout: null,
|
|
597
|
+
mailLayout:
|
|
598
|
+
rendered.layout === undefined
|
|
599
|
+
? null
|
|
600
|
+
: {
|
|
601
|
+
slug: mailMeta.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG,
|
|
602
|
+
scope: rendered.layout.scope,
|
|
603
|
+
version: rendered.layout.templateVersion,
|
|
604
|
+
},
|
|
554
605
|
sampleValues,
|
|
555
606
|
checks: [
|
|
556
|
-
missingResourceCheck(unresolved),
|
|
607
|
+
missingResourceCheck([...unresolved, ...missingAttachments]),
|
|
557
608
|
{
|
|
558
609
|
code: "text_present",
|
|
559
610
|
ok: rendered.text.trim() !== "",
|
|
@@ -571,3 +622,55 @@ export async function renderTemplatePreview(
|
|
|
571
622
|
},
|
|
572
623
|
};
|
|
573
624
|
}
|
|
625
|
+
|
|
626
|
+
// Renders a draft (or, without content, the stored template) with sample values, going through
|
|
627
|
+
// the same write gates and the same mail renderer as a real send. Nothing is stored or sent.
|
|
628
|
+
export async function renderTemplatePreview(
|
|
629
|
+
ctx: HandlerContext,
|
|
630
|
+
user: SessionUser,
|
|
631
|
+
input: TemplatePreviewInput,
|
|
632
|
+
): Promise<TemplatePreviewOutcome> {
|
|
633
|
+
const draftSource = await resolveDraftSource(ctx, user, input);
|
|
634
|
+
if (draftSource === null) return failed(new NotFoundError("template-resource", input.slug));
|
|
635
|
+
const {
|
|
636
|
+
content: draftContent,
|
|
637
|
+
meta: draftMeta,
|
|
638
|
+
variableSchema: draftVariableSchema,
|
|
639
|
+
} = draftSource;
|
|
640
|
+
|
|
641
|
+
const { response: validation, evaluation } = await validateTemplateDraft(ctx, user, {
|
|
642
|
+
slug: input.slug,
|
|
643
|
+
kind: input.kind,
|
|
644
|
+
contentFormat: "html",
|
|
645
|
+
content: draftContent,
|
|
646
|
+
meta: draftMeta,
|
|
647
|
+
variableSchema: draftVariableSchema,
|
|
648
|
+
locale: input.locale,
|
|
649
|
+
scope: "tenant",
|
|
650
|
+
});
|
|
651
|
+
if (!evaluation.ok) return failed(new ValidationError({ fields: [...evaluation.issues] }));
|
|
652
|
+
|
|
653
|
+
const content = evaluation.content ?? "";
|
|
654
|
+
const meta = parseTemplateMeta(input.kind, parseStoredJson(evaluation.metaJson));
|
|
655
|
+
const sampleValues = sampleValuesFor(validation.variables);
|
|
656
|
+
|
|
657
|
+
if (input.kind === "document-pdf") {
|
|
658
|
+
return renderDocumentPreview(ctx, user, {
|
|
659
|
+
input,
|
|
660
|
+
content,
|
|
661
|
+
meta,
|
|
662
|
+
variableSchema: draftVariableSchema,
|
|
663
|
+
sampleValues,
|
|
664
|
+
validation,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return renderMailPreview(ctx, user, {
|
|
669
|
+
input,
|
|
670
|
+
content,
|
|
671
|
+
meta,
|
|
672
|
+
variableSchema: draftVariableSchema,
|
|
673
|
+
sampleValues,
|
|
674
|
+
validation,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
@@ -6,7 +6,8 @@ import type {
|
|
|
6
6
|
WriteResult,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
9
|
+
import { createFileRefFromBytes, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
import { fileContextForTenant } from "../file-foundation/index.js";
|
|
10
11
|
import { requireForTenant } from "../shared/index.js";
|
|
11
12
|
import { executor } from "./handlers/shared.js";
|
|
12
13
|
import type { LinkedResourceEntry } from "./linked-resources.js";
|
|
@@ -35,13 +36,52 @@ export async function readTemplateResource(
|
|
|
35
36
|
id: fileRefId,
|
|
36
37
|
isDeleted: false,
|
|
37
38
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const handle = ctx.files.ref(row.storageKey);
|
|
39
|
+
if (!row) return null;
|
|
40
|
+
const handle = ctx.files.refFromFileRef(row);
|
|
41
41
|
if (!(await handle.exists())) return null;
|
|
42
42
|
return { fileName: row.fileName, contentType: row.mimeType, bytes: await handle.read() };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Gives the target tenant its own copy of every file behind the entries, so a template that was
|
|
46
|
+
// copied from the system default keeps working once the tenant exports it or measures its
|
|
47
|
+
// attachments. Rendered-PDF links carry no file and pass through. An entry whose source file is
|
|
48
|
+
// gone is left out: it could not be resolved at render time either.
|
|
49
|
+
export async function duplicateLinkedResourceFiles(
|
|
50
|
+
ctx: HandlerContext,
|
|
51
|
+
args: {
|
|
52
|
+
readonly user: SessionUser;
|
|
53
|
+
readonly tenantId: TenantId;
|
|
54
|
+
readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
|
|
55
|
+
},
|
|
56
|
+
): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
|
|
57
|
+
const { user, tenantId } = args;
|
|
58
|
+
const { db } = requireForTenant(ctx, tenantId);
|
|
59
|
+
const copies: Record<string, LinkedResourceEntry> = {};
|
|
60
|
+
for (const [key, entry] of Object.entries(args.resources)) {
|
|
61
|
+
if (typeof entry !== "string" && entry.kind === "rendered-pdf") {
|
|
62
|
+
copies[key] = entry;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const sourceFileRefId = typeof entry === "string" ? entry : entry.fileRefId;
|
|
66
|
+
const file = await readTemplateResource(ctx, sourceFileRefId);
|
|
67
|
+
// skip: the source file is gone, there is nothing to copy
|
|
68
|
+
if (file === null) continue;
|
|
69
|
+
const created = await createFileRefFromBytes(
|
|
70
|
+
{
|
|
71
|
+
db,
|
|
72
|
+
files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
|
|
73
|
+
registry: ctx.registry,
|
|
74
|
+
user: { ...user, tenantId },
|
|
75
|
+
},
|
|
76
|
+
{ fileName: file.fileName, mimeType: file.contentType, data: file.bytes },
|
|
77
|
+
);
|
|
78
|
+
if (!created.isSuccess) return created;
|
|
79
|
+
copies[key] =
|
|
80
|
+
typeof entry === "string" ? created.data.id : { ...entry, fileRefId: created.data.id };
|
|
81
|
+
}
|
|
82
|
+
return { isSuccess: true, data: copies };
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
// Replaces the whole key map of one row with files that are already stored (bundle import,
|
|
46
86
|
// copy-locale). Single keys go through storeTemplateResource and resource-remove.
|
|
47
87
|
export async function attachTemplateResources(
|