@cosmicdrift/kumiko-samples 0.362.0 → 0.363.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 +17 -6
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
- package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +14 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
- package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
- package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
- package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +38 -44
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +115 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +30 -30
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
- package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +3 -6
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
- package/packages/bundled-features/src/channel-email/changes.json +19 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +122 -29
- package/packages/bundled-features/src/channel-email/feature.ts +6 -3
- package/packages/bundled-features/src/channel-email/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
- package/packages/bundled-features/src/channel-email/types.ts +23 -0
- package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
- package/packages/bundled-features/src/config/handlers/set.write.ts +1 -5
- package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
- package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
- package/packages/bundled-features/src/data-retention/changes.json +6 -0
- package/packages/bundled-features/src/data-retention/index.ts +1 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
- package/packages/bundled-features/src/delivery/attachment-ref.ts +84 -0
- package/packages/bundled-features/src/delivery/changes.json +48 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +129 -4
- package/packages/bundled-features/src/delivery/constants.ts +2 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/index.ts +17 -2
- package/packages/bundled-features/src/delivery/jobs.ts +48 -5
- package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
- package/packages/bundled-features/src/delivery/testing.ts +9 -1
- package/packages/bundled-features/src/delivery/types.ts +28 -18
- package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
- package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
- package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
- package/packages/bundled-features/src/file-foundation/index.ts +7 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +86 -0
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
- package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
- package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +40 -25
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
- package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
- package/packages/bundled-features/src/ledger/recurring.ts +1 -3
- package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
- package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
- package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
- package/packages/bundled-features/src/page-render/branding.ts +3 -0
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
- package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
- package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
- package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
- package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
- package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
- package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
- package/packages/bundled-features/src/renderer-foundation/types.ts +132 -35
- package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
- package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/sessions/feature.ts +2 -4
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
- package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +25 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
- package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
- package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
- package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
- package/packages/bundled-features/src/template-resolver/README.md +63 -7
- package/packages/bundled-features/src/template-resolver/api.ts +83 -8
- package/packages/bundled-features/src/template-resolver/changes.json +118 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +214 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +39 -11
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +323 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +56 -6
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +142 -0
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +72 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +83 -0
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +108 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +5 -7
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +250 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +106 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +184 -0
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +183 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +55 -0
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +57 -10
- package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +15 -76
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +17 -0
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
- package/packages/bundled-features/src/template-resolver/index.ts +100 -0
- package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
- package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
- package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
- package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +30 -0
- package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +147 -17
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
- package/packages/bundled-features/src/template-resolver/table.ts +14 -0
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
- package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +129 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +573 -0
- package/packages/bundled-features/src/template-resolver/template-resources.ts +74 -0
- package/packages/bundled-features/src/template-resolver/template-usage.ts +48 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +229 -0
- package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +9 -6
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +162 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +28 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +850 -0
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +61 -0
- package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +100 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +19 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +169 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
- package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
- package/packages/bundled-features/src/tenant/seeding.ts +41 -7
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
- package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
- package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
- package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
- package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
- package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
- package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
- package/packages/bundled-features/src/waitlist/changes.json +7 -0
- package/packages/bundled-features/src/waitlist/feature.ts +17 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
- package/packages/bundled-features/src/waitlist/mail.ts +69 -18
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +1 -2
- package/samples/apps/ui-walkthrough/package.json +2 -0
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
- package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +154 -0
- package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +354 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +68 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
- package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +37 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +115 -20
- package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +2 -0
- package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
- package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
- package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
- package/samples/recipes/delivery-notifications/README.md +26 -25
- package/samples/recipes/delivery-notifications/package.json +2 -1
- package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
- package/samples/recipes/seed-migration/README.md +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
- package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
- package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
- package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type {
|
|
3
|
+
HandlerContext,
|
|
4
|
+
SessionUser,
|
|
5
|
+
TemplateListVariableDef,
|
|
6
|
+
TemplateScalarVariableDef,
|
|
7
|
+
TemplateVariables,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
type KumikoError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
UnprocessableError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
15
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import { createEmailAttachmentReader, createTemplateMailRenderer } from "../delivery/index.js";
|
|
17
|
+
import { fileContextForTenant } from "../file-foundation/index.js";
|
|
18
|
+
import {
|
|
19
|
+
collectRendererPlugins,
|
|
20
|
+
createRendererFoundationApi,
|
|
21
|
+
type MailAttachmentRef,
|
|
22
|
+
RendererError,
|
|
23
|
+
type RendererPlugin,
|
|
24
|
+
} from "../renderer-foundation/index.js";
|
|
25
|
+
import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResource } from "./api.js";
|
|
26
|
+
import { collectDocumentResourceKeys } from "./compile-document-body.js";
|
|
27
|
+
import { MAIL_PREVIEW_MAX_BYTES, SYSTEM_TENANT_ID, type TemplateScope } from "./constants.js";
|
|
28
|
+
import { parseStoredJson } from "./handlers/template-gates.js";
|
|
29
|
+
import { validateTemplateDraft } from "./handlers/validate.query.js";
|
|
30
|
+
import { type LayoutRegions, readLayoutRegions, sanitizeLayoutHtml } from "./layout-regions.js";
|
|
31
|
+
import { type LinkedResourceEntry, parseLinkedResourceEntries } from "./linked-resources.js";
|
|
32
|
+
import { buildDisplayHtml } from "./preview-display-html.js";
|
|
33
|
+
import { TemplateResolverErrors } from "./qualified-names.js";
|
|
34
|
+
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
35
|
+
import {
|
|
36
|
+
isMailTemplateMeta,
|
|
37
|
+
type MailTemplateMeta,
|
|
38
|
+
type PdfTemplateMeta,
|
|
39
|
+
parseTemplateMeta,
|
|
40
|
+
readPdfTemplateMeta,
|
|
41
|
+
} from "./template-meta.js";
|
|
42
|
+
|
|
43
|
+
const SAMPLE_LINK = "https://example.com";
|
|
44
|
+
const SAMPLE_DATE = "2026-01-15";
|
|
45
|
+
const SAMPLE_LIST_ROWS = 2;
|
|
46
|
+
const DEFAULT_PDF_LAYOUT_SLUG = "default";
|
|
47
|
+
|
|
48
|
+
function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
|
|
49
|
+
if (def.example !== undefined) return def.example;
|
|
50
|
+
switch (def.type) {
|
|
51
|
+
case "text":
|
|
52
|
+
return `[${def.label ?? name}]`;
|
|
53
|
+
case "link":
|
|
54
|
+
return SAMPLE_LINK;
|
|
55
|
+
case "money":
|
|
56
|
+
return 0;
|
|
57
|
+
case "date":
|
|
58
|
+
return SAMPLE_DATE;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sampleRow(
|
|
63
|
+
item: TemplateListVariableDef["item"],
|
|
64
|
+
given: Readonly<Record<string, unknown>> = {},
|
|
65
|
+
): Record<string, unknown> {
|
|
66
|
+
const row: Record<string, unknown> = {};
|
|
67
|
+
for (const [name, def] of Object.entries(item)) row[name] = sampleScalar(name, def);
|
|
68
|
+
return { ...row, ...given };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sampleList(def: TemplateListVariableDef): Record<string, unknown>[] {
|
|
72
|
+
const rows = (def.example ?? [])
|
|
73
|
+
.slice(0, SAMPLE_LIST_ROWS)
|
|
74
|
+
.map((given) => sampleRow(def.item, given));
|
|
75
|
+
while (rows.length < SAMPLE_LIST_ROWS) rows.push(sampleRow(def.item));
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Declared examples win; the fallbacks are deterministic so a preview never differs between calls.
|
|
80
|
+
export function sampleValuesFor(variables: TemplateVariables): Record<string, unknown> {
|
|
81
|
+
const values: Record<string, unknown> = {};
|
|
82
|
+
for (const [name, def] of Object.entries(variables)) {
|
|
83
|
+
values[name] = def.type === "list" ? sampleList(def) : sampleScalar(name, def);
|
|
84
|
+
}
|
|
85
|
+
return values;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type PreviewCheckCode =
|
|
89
|
+
| "resources_resolved"
|
|
90
|
+
| "text_present"
|
|
91
|
+
| "size_under_cap"
|
|
92
|
+
| "required_placeholders";
|
|
93
|
+
|
|
94
|
+
export type PreviewCheck = {
|
|
95
|
+
readonly code: PreviewCheckCode;
|
|
96
|
+
readonly ok: boolean;
|
|
97
|
+
readonly i18nKey: string;
|
|
98
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type PreviewAttachment =
|
|
102
|
+
| {
|
|
103
|
+
readonly kind: "inline";
|
|
104
|
+
readonly cid: string;
|
|
105
|
+
readonly name: string;
|
|
106
|
+
readonly sizeBytes: number;
|
|
107
|
+
}
|
|
108
|
+
| { readonly kind: "file"; readonly name: string; readonly sizeBytes: number }
|
|
109
|
+
// Rendered when the mail is sent, so the preview knows the name but not the size.
|
|
110
|
+
| {
|
|
111
|
+
readonly kind: "rendered-pdf";
|
|
112
|
+
readonly name: string;
|
|
113
|
+
readonly templateSlug: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type PreviewLayout = {
|
|
117
|
+
readonly slug: string;
|
|
118
|
+
readonly scope: TemplateScope;
|
|
119
|
+
readonly html: string;
|
|
120
|
+
readonly regions: LayoutRegions;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type TemplatePreviewInput = {
|
|
124
|
+
readonly slug: string;
|
|
125
|
+
readonly kind: "mail" | "document-pdf";
|
|
126
|
+
readonly locale: string;
|
|
127
|
+
readonly content?: string | undefined;
|
|
128
|
+
readonly meta?: unknown;
|
|
129
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type ValidationResponse = Awaited<ReturnType<typeof validateTemplateDraft>>["response"];
|
|
133
|
+
|
|
134
|
+
export type TemplatePreview = {
|
|
135
|
+
readonly kind: TemplatePreviewInput["kind"];
|
|
136
|
+
readonly subject: string | null;
|
|
137
|
+
readonly previewText: string | null;
|
|
138
|
+
readonly html: string;
|
|
139
|
+
/** Mail only: `html` with resolved inline images as data URIs, for showing in a browser. */
|
|
140
|
+
readonly displayHtml?: string;
|
|
141
|
+
readonly text: string | null;
|
|
142
|
+
readonly attachments: readonly PreviewAttachment[];
|
|
143
|
+
readonly layout: PreviewLayout | null;
|
|
144
|
+
readonly sampleValues: Readonly<Record<string, unknown>>;
|
|
145
|
+
readonly checks: readonly PreviewCheck[];
|
|
146
|
+
readonly validation: ValidationResponse;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export type TemplatePreviewOutcome =
|
|
150
|
+
| {
|
|
151
|
+
readonly ok: true;
|
|
152
|
+
readonly preview: TemplatePreview;
|
|
153
|
+
/** What the send path needs to attach; kept out of the response shown to the editor. */
|
|
154
|
+
readonly mailAttachments: readonly MailAttachmentRef[];
|
|
155
|
+
}
|
|
156
|
+
| { readonly ok: false; readonly error: KumikoError };
|
|
157
|
+
|
|
158
|
+
const utf8Bytes = (value: string): number => new TextEncoder().encode(value).length;
|
|
159
|
+
|
|
160
|
+
const failed = (error: KumikoError): TemplatePreviewOutcome => ({ ok: false, error });
|
|
161
|
+
|
|
162
|
+
async function loadStoredDraft(
|
|
163
|
+
ctx: HandlerContext,
|
|
164
|
+
user: SessionUser,
|
|
165
|
+
input: TemplatePreviewInput,
|
|
166
|
+
): Promise<TemplateResource | null> {
|
|
167
|
+
try {
|
|
168
|
+
return await createTemplateResolverApi(ctx.db).resolveTemplate({
|
|
169
|
+
tenantId: user.tenantId,
|
|
170
|
+
slug: input.slug,
|
|
171
|
+
kind: input.kind,
|
|
172
|
+
locale: input.locale,
|
|
173
|
+
});
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (e instanceof TemplateNotFoundError) return null;
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function describeAttachments(
|
|
181
|
+
ctx: HandlerContext,
|
|
182
|
+
refs: readonly MailAttachmentRef[],
|
|
183
|
+
): Promise<PreviewAttachment[]> {
|
|
184
|
+
const fileRefIds = refs.flatMap((ref) => (ref.kind === "rendered-pdf" ? [] : [ref.fileRefId]));
|
|
185
|
+
const rows =
|
|
186
|
+
fileRefIds.length === 0
|
|
187
|
+
? []
|
|
188
|
+
: await selectMany<{ id: string; size: number | bigint | string }>(ctx.db, fileRefsTable, {
|
|
189
|
+
id: fileRefIds,
|
|
190
|
+
});
|
|
191
|
+
const sizeById = new Map(rows.map((row) => [row.id, Number(row.size)]));
|
|
192
|
+
// An inline image is named by its content id because the stored file name is personal data and
|
|
193
|
+
// may be ciphertext; a file attachment carries the name the admin gave it.
|
|
194
|
+
return refs.map((ref) => previewAttachmentOf(ref, sizeById));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function previewAttachmentOf(
|
|
198
|
+
ref: MailAttachmentRef,
|
|
199
|
+
sizeById: ReadonlyMap<string, number>,
|
|
200
|
+
): PreviewAttachment {
|
|
201
|
+
switch (ref.kind) {
|
|
202
|
+
case "inline":
|
|
203
|
+
return {
|
|
204
|
+
kind: "inline",
|
|
205
|
+
cid: ref.cid,
|
|
206
|
+
name: ref.cid,
|
|
207
|
+
sizeBytes: sizeById.get(ref.fileRefId) ?? 0,
|
|
208
|
+
};
|
|
209
|
+
case "file":
|
|
210
|
+
return { kind: "file", name: ref.fileName, sizeBytes: sizeById.get(ref.fileRefId) ?? 0 };
|
|
211
|
+
case "rendered-pdf":
|
|
212
|
+
return { kind: "rendered-pdf", name: ref.fileName, templateSlug: ref.templateSlug };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const attachmentSizeOf = (attachment: PreviewAttachment): number =>
|
|
217
|
+
attachment.kind === "rendered-pdf" ? 0 : attachment.sizeBytes;
|
|
218
|
+
|
|
219
|
+
// The row an editor is working on, whatever its status: the stored template only resolves when
|
|
220
|
+
// active, so a draft would otherwise lose the images and attachments uploaded to it.
|
|
221
|
+
async function loadEditedRowResources(
|
|
222
|
+
ctx: HandlerContext,
|
|
223
|
+
user: SessionUser,
|
|
224
|
+
input: TemplatePreviewInput,
|
|
225
|
+
): Promise<Record<string, LinkedResourceEntry> | undefined> {
|
|
226
|
+
const rows = await selectMany<TemplateResourceRow>(
|
|
227
|
+
ctx.db,
|
|
228
|
+
templateResourcesTable,
|
|
229
|
+
{ tenantId: user.tenantId, slug: input.slug, kind: "mail", locale: input.locale },
|
|
230
|
+
{ limit: 1 },
|
|
231
|
+
);
|
|
232
|
+
const row = rows[0];
|
|
233
|
+
return row === undefined ? undefined : parseLinkedResourceEntries(row.linkedResources);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function resolvePdfLayout(
|
|
237
|
+
ctx: HandlerContext,
|
|
238
|
+
user: SessionUser,
|
|
239
|
+
slug: string,
|
|
240
|
+
locale: string,
|
|
241
|
+
): Promise<PreviewLayout | null> {
|
|
242
|
+
const api = createTemplateResolverApi(ctx.db);
|
|
243
|
+
const find = async (tenantId: string): Promise<TemplateResource | null> => {
|
|
244
|
+
try {
|
|
245
|
+
return await api.resolveLayout({ tenantId, slug, kind: "pdf-layout", locale });
|
|
246
|
+
} catch (e) {
|
|
247
|
+
if (e instanceof TemplateNotFoundError) return null;
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
let layout = await find(user.tenantId);
|
|
252
|
+
let html = layout?.content ?? "";
|
|
253
|
+
if (layout !== null && layout.scope !== "system") {
|
|
254
|
+
// Re-checked like the mail renderer does: a tenant row that predates the write gate must not pass.
|
|
255
|
+
const sanitized = sanitizeLayoutHtml(html);
|
|
256
|
+
if (sanitized.ok) html = sanitized.html;
|
|
257
|
+
else {
|
|
258
|
+
layout = await find(SYSTEM_TENANT_ID);
|
|
259
|
+
html = layout?.content ?? "";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (layout === null) return null;
|
|
263
|
+
return { slug, scope: layout.scope, html, regions: readLayoutRegions(html) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function missingResourceCheck(missing: readonly string[]): PreviewCheck {
|
|
267
|
+
return {
|
|
268
|
+
code: "resources_resolved",
|
|
269
|
+
ok: missing.length === 0,
|
|
270
|
+
i18nKey: "templateResolver.checks.resourcesResolved",
|
|
271
|
+
params: { missing },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function requiredPlaceholdersCheck(missing: readonly string[]): PreviewCheck {
|
|
276
|
+
return {
|
|
277
|
+
code: "required_placeholders",
|
|
278
|
+
ok: missing.length === 0,
|
|
279
|
+
i18nKey: "templateResolver.checks.requiredPlaceholders",
|
|
280
|
+
params: { missing },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function toRenderFailure(e: unknown): TemplatePreviewOutcome {
|
|
285
|
+
if (!(e instanceof RendererError)) throw e;
|
|
286
|
+
return failed(
|
|
287
|
+
new UnprocessableError(TemplateResolverErrors.previewRenderFailed, {
|
|
288
|
+
i18nKey: "templateResolver.errors.preview.renderFailed",
|
|
289
|
+
details: { code: e.code },
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type DocumentPreviewArgs = {
|
|
295
|
+
readonly input: TemplatePreviewInput;
|
|
296
|
+
readonly content: string;
|
|
297
|
+
readonly meta: ReturnType<typeof parseTemplateMeta>;
|
|
298
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
299
|
+
readonly sampleValues: Readonly<Record<string, unknown>>;
|
|
300
|
+
readonly validation: ValidationResponse;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Only a plugin that prints documents can show the filled-in page; without one the editor
|
|
304
|
+
// still gets the body and the raw layout, just without placeholder values.
|
|
305
|
+
function documentPreviewPlugin(ctx: HandlerContext, tenantId: string): RendererPlugin | null {
|
|
306
|
+
try {
|
|
307
|
+
const plugin = createRendererFoundationApi(
|
|
308
|
+
collectRendererPlugins(ctx.registry),
|
|
309
|
+
).createRendererForTenant({ tenantId, kind: "document-pdf" });
|
|
310
|
+
return plugin.previewDocument === undefined ? null : plugin;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (e instanceof RendererError && e.code === "no_plugin_for_kind") return null;
|
|
313
|
+
throw e;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function renderDocumentPreview(
|
|
318
|
+
ctx: HandlerContext,
|
|
319
|
+
user: SessionUser,
|
|
320
|
+
args: DocumentPreviewArgs,
|
|
321
|
+
): Promise<TemplatePreviewOutcome> {
|
|
322
|
+
const { input, content, meta, validation, sampleValues } = args;
|
|
323
|
+
const pdfMeta = meta.ok ? readPdfTemplateMeta(meta.meta) : null;
|
|
324
|
+
const requiredCheck = requiredPlaceholdersCheck(validation.missingRequired);
|
|
325
|
+
const plugin = documentPreviewPlugin(ctx, user.tenantId);
|
|
326
|
+
|
|
327
|
+
if (plugin?.previewDocument === undefined) {
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
mailAttachments: [],
|
|
331
|
+
preview: {
|
|
332
|
+
kind: "document-pdf",
|
|
333
|
+
subject: null,
|
|
334
|
+
previewText: null,
|
|
335
|
+
html: content,
|
|
336
|
+
text: null,
|
|
337
|
+
attachments: [],
|
|
338
|
+
layout: await resolvePdfLayout(
|
|
339
|
+
ctx,
|
|
340
|
+
user,
|
|
341
|
+
pdfMeta?.layoutSlug ?? DEFAULT_PDF_LAYOUT_SLUG,
|
|
342
|
+
input.locale,
|
|
343
|
+
),
|
|
344
|
+
sampleValues,
|
|
345
|
+
checks: [missingResourceCheck(validation.warnings.map((w) => w.detail)), requiredCheck],
|
|
346
|
+
validation,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let document: Awaited<ReturnType<NonNullable<RendererPlugin["previewDocument"]>>>;
|
|
352
|
+
try {
|
|
353
|
+
document = await plugin.previewDocument(
|
|
354
|
+
{
|
|
355
|
+
slug: input.slug,
|
|
356
|
+
locale: input.locale,
|
|
357
|
+
variables: sampleValues,
|
|
358
|
+
draft: { content, meta: pdfMeta, variableSchema: args.variableSchema },
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
db: ctx.db,
|
|
362
|
+
registry: ctx.registry,
|
|
363
|
+
tenantId: user.tenantId,
|
|
364
|
+
...(ctx.config !== undefined && { config: ctx.config }),
|
|
365
|
+
filesForTenant: (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
return toRenderFailure(e);
|
|
370
|
+
}
|
|
371
|
+
const layout = document.layout;
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
mailAttachments: [],
|
|
375
|
+
preview: {
|
|
376
|
+
kind: "document-pdf",
|
|
377
|
+
subject: null,
|
|
378
|
+
previewText: null,
|
|
379
|
+
html: document.bodyHtml,
|
|
380
|
+
text: null,
|
|
381
|
+
attachments: [],
|
|
382
|
+
layout:
|
|
383
|
+
layout === null
|
|
384
|
+
? null
|
|
385
|
+
: {
|
|
386
|
+
slug: layout.slug,
|
|
387
|
+
scope: layout.scope,
|
|
388
|
+
html: `${layout.headerHtml}${layout.signatureHtml}${layout.footerHtml}`,
|
|
389
|
+
regions: {
|
|
390
|
+
header: layout.headerHtml,
|
|
391
|
+
signature: layout.signatureHtml,
|
|
392
|
+
footer: layout.footerHtml,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
sampleValues,
|
|
396
|
+
checks: [missingResourceCheck(document.missingResourceKeys), requiredCheck],
|
|
397
|
+
validation,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export type DocumentDraftOutcome =
|
|
403
|
+
| {
|
|
404
|
+
readonly ok: true;
|
|
405
|
+
readonly content: string;
|
|
406
|
+
readonly meta: PdfTemplateMeta | null;
|
|
407
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
408
|
+
readonly sampleValues: Readonly<Record<string, unknown>>;
|
|
409
|
+
}
|
|
410
|
+
| { readonly ok: false; readonly error: KumikoError };
|
|
411
|
+
|
|
412
|
+
// A document draft is always tenant-scoped here, like in the preview: it passes the write gates
|
|
413
|
+
// and a failing draft is rejected with the same problems as a save.
|
|
414
|
+
export async function evaluateDocumentDraft(
|
|
415
|
+
ctx: HandlerContext,
|
|
416
|
+
user: SessionUser,
|
|
417
|
+
draft: {
|
|
418
|
+
readonly slug: string;
|
|
419
|
+
readonly locale: string;
|
|
420
|
+
readonly content: string;
|
|
421
|
+
readonly meta?: unknown;
|
|
422
|
+
readonly variableSchema: Readonly<Record<string, unknown>>;
|
|
423
|
+
},
|
|
424
|
+
): Promise<DocumentDraftOutcome> {
|
|
425
|
+
const { response, evaluation } = await validateTemplateDraft(ctx, user, {
|
|
426
|
+
slug: draft.slug,
|
|
427
|
+
kind: "document-pdf",
|
|
428
|
+
contentFormat: "html",
|
|
429
|
+
content: draft.content,
|
|
430
|
+
meta: draft.meta,
|
|
431
|
+
variableSchema: draft.variableSchema,
|
|
432
|
+
locale: draft.locale,
|
|
433
|
+
scope: "tenant",
|
|
434
|
+
});
|
|
435
|
+
if (!evaluation.ok) {
|
|
436
|
+
return { ok: false, error: new ValidationError({ fields: [...evaluation.issues] }) };
|
|
437
|
+
}
|
|
438
|
+
const meta = parseTemplateMeta("document-pdf", parseStoredJson(evaluation.metaJson));
|
|
439
|
+
return {
|
|
440
|
+
ok: true,
|
|
441
|
+
content: evaluation.content ?? "",
|
|
442
|
+
meta: meta.ok ? readPdfTemplateMeta(meta.meta) : null,
|
|
443
|
+
variableSchema: draft.variableSchema,
|
|
444
|
+
sampleValues: sampleValuesFor(response.variables),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Renders a draft (or, without content, the stored template) with sample values, going through
|
|
449
|
+
// the same write gates and the same mail renderer as a real send. Nothing is stored or sent.
|
|
450
|
+
export async function renderTemplatePreview(
|
|
451
|
+
ctx: HandlerContext,
|
|
452
|
+
user: SessionUser,
|
|
453
|
+
input: TemplatePreviewInput,
|
|
454
|
+
): Promise<TemplatePreviewOutcome> {
|
|
455
|
+
let draftContent = input.content;
|
|
456
|
+
let draftMeta: unknown = input.meta;
|
|
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;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const { response: validation, evaluation } = await validateTemplateDraft(ctx, user, {
|
|
467
|
+
slug: input.slug,
|
|
468
|
+
kind: input.kind,
|
|
469
|
+
contentFormat: "html",
|
|
470
|
+
content: draftContent,
|
|
471
|
+
meta: draftMeta,
|
|
472
|
+
variableSchema: draftVariableSchema,
|
|
473
|
+
locale: input.locale,
|
|
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
|
+
|
|
493
|
+
if (!meta.ok || !isMailTemplateMeta(meta.meta)) {
|
|
494
|
+
return failed(
|
|
495
|
+
new UnprocessableError(TemplateResolverErrors.previewRenderFailed, {
|
|
496
|
+
i18nKey: "templateResolver.errors.preview.renderFailed",
|
|
497
|
+
details: { code: "invalid_meta" },
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const mailMeta: MailTemplateMeta = meta.meta;
|
|
502
|
+
const editedResources =
|
|
503
|
+
input.content === undefined ? undefined : await loadEditedRowResources(ctx, user, input);
|
|
504
|
+
let rendered: Awaited<ReturnType<ReturnType<typeof createTemplateMailRenderer>>>;
|
|
505
|
+
try {
|
|
506
|
+
rendered = await createTemplateMailRenderer(
|
|
507
|
+
ctx.db,
|
|
508
|
+
ctx.registry,
|
|
509
|
+
user.tenantId,
|
|
510
|
+
ctx.config,
|
|
511
|
+
)({
|
|
512
|
+
slug: input.slug,
|
|
513
|
+
locale: input.locale,
|
|
514
|
+
variables: sampleValues,
|
|
515
|
+
draft: {
|
|
516
|
+
content,
|
|
517
|
+
meta: mailMeta,
|
|
518
|
+
variableSchema: draftVariableSchema,
|
|
519
|
+
...(editedResources !== undefined && { linkedResources: editedResources }),
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
} catch (e) {
|
|
523
|
+
return toRenderFailure(e);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const mailAttachments = rendered.attachments ?? [];
|
|
527
|
+
const inline = mailAttachments.filter((ref) => ref.kind === "inline");
|
|
528
|
+
const attachments = await describeAttachments(ctx, mailAttachments);
|
|
529
|
+
const displayHtml = await buildDisplayHtml(
|
|
530
|
+
rendered.html,
|
|
531
|
+
inline,
|
|
532
|
+
createEmailAttachmentReader(ctx.db, ctx.registry, user.tenantId, ctx.config, (tenantId) =>
|
|
533
|
+
fileContextForTenant(ctx, tenantId),
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
const resolvedCids = new Set(inline.map((ref) => ref.cid));
|
|
537
|
+
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);
|
|
542
|
+
return {
|
|
543
|
+
ok: true,
|
|
544
|
+
mailAttachments,
|
|
545
|
+
preview: {
|
|
546
|
+
kind: "mail",
|
|
547
|
+
subject: rendered.subject,
|
|
548
|
+
previewText: rendered.previewText ?? null,
|
|
549
|
+
html: rendered.html,
|
|
550
|
+
displayHtml,
|
|
551
|
+
text: rendered.text,
|
|
552
|
+
attachments,
|
|
553
|
+
layout: null,
|
|
554
|
+
sampleValues,
|
|
555
|
+
checks: [
|
|
556
|
+
missingResourceCheck(unresolved),
|
|
557
|
+
{
|
|
558
|
+
code: "text_present",
|
|
559
|
+
ok: rendered.text.trim() !== "",
|
|
560
|
+
i18nKey: "templateResolver.checks.textPresent",
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
code: "size_under_cap",
|
|
564
|
+
ok: totalBytes <= MAIL_PREVIEW_MAX_BYTES,
|
|
565
|
+
i18nKey: "templateResolver.checks.sizeUnderCap",
|
|
566
|
+
params: { totalBytes, maxBytes: MAIL_PREVIEW_MAX_BYTES },
|
|
567
|
+
},
|
|
568
|
+
requiredPlaceholdersCheck(validation.missingRequired),
|
|
569
|
+
],
|
|
570
|
+
validation,
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type {
|
|
3
|
+
HandlerContext,
|
|
4
|
+
SessionUser,
|
|
5
|
+
TenantId,
|
|
6
|
+
WriteResult,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
import { requireForTenant } from "../shared/index.js";
|
|
11
|
+
import { executor } from "./handlers/shared.js";
|
|
12
|
+
import type { LinkedResourceEntry } from "./linked-resources.js";
|
|
13
|
+
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
14
|
+
|
|
15
|
+
export type TemplateResourceFile = {
|
|
16
|
+
readonly fileName: string;
|
|
17
|
+
readonly contentType: string;
|
|
18
|
+
readonly bytes: Uint8Array;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type FileRefRow = {
|
|
22
|
+
readonly storageKey: string;
|
|
23
|
+
readonly fileName: string;
|
|
24
|
+
readonly mimeType: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Reads the file behind a template resource. Null when the fileRef is unknown
|
|
28
|
+
// to the caller's tenant or has no bytes.
|
|
29
|
+
export async function readTemplateResource(
|
|
30
|
+
ctx: Pick<HandlerContext, "db" | "files" | "user">,
|
|
31
|
+
fileRefId: string,
|
|
32
|
+
): Promise<TemplateResourceFile | null> {
|
|
33
|
+
if (!ctx.files) return null;
|
|
34
|
+
const row = await fetchOne<FileRefRow>(ctx.db, fileRefsTable, {
|
|
35
|
+
id: fileRefId,
|
|
36
|
+
isDeleted: false,
|
|
37
|
+
});
|
|
38
|
+
// ctx.files only reaches the caller's own key space.
|
|
39
|
+
if (!row?.storageKey.startsWith(`${ctx.user.tenantId}/`)) return null;
|
|
40
|
+
const handle = ctx.files.ref(row.storageKey);
|
|
41
|
+
if (!(await handle.exists())) return null;
|
|
42
|
+
return { fileName: row.fileName, contentType: row.mimeType, bytes: await handle.read() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Replaces the whole key map of one row with files that are already stored (bundle import,
|
|
46
|
+
// copy-locale). Single keys go through storeTemplateResource and resource-remove.
|
|
47
|
+
export async function attachTemplateResources(
|
|
48
|
+
ctx: Pick<HandlerContext, "forTenant">,
|
|
49
|
+
args: {
|
|
50
|
+
readonly user: SessionUser;
|
|
51
|
+
readonly tenantId: TenantId;
|
|
52
|
+
readonly templateId: string;
|
|
53
|
+
readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
|
|
54
|
+
},
|
|
55
|
+
): Promise<WriteResult<null>> {
|
|
56
|
+
if (Object.keys(args.resources).length === 0) return { isSuccess: true, data: null };
|
|
57
|
+
const { db, streamTenantId } = requireForTenant(ctx, args.tenantId);
|
|
58
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
59
|
+
id: args.templateId,
|
|
60
|
+
tenantId: args.tenantId,
|
|
61
|
+
});
|
|
62
|
+
if (!row) return writeFailure(new NotFoundError("template-resource", args.templateId));
|
|
63
|
+
const result = await executor.update(
|
|
64
|
+
{
|
|
65
|
+
id: row.id,
|
|
66
|
+
version: row.version,
|
|
67
|
+
changes: { linkedResources: JSON.stringify(args.resources) },
|
|
68
|
+
},
|
|
69
|
+
args.user,
|
|
70
|
+
db,
|
|
71
|
+
{ streamTenantId },
|
|
72
|
+
);
|
|
73
|
+
return result.isSuccess ? { isSuccess: true, data: null } : result;
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { parseQn, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { DEFAULT_MAIL_LAYOUT_SLUG, type TemplateKind } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
type UsageRole = "template" | "layout";
|
|
5
|
+
|
|
6
|
+
export type TemplateUsageEntry = {
|
|
7
|
+
readonly qualifiedName: string;
|
|
8
|
+
readonly feature: string;
|
|
9
|
+
readonly notification: string;
|
|
10
|
+
readonly trigger: string | null;
|
|
11
|
+
readonly role: UsageRole;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function collectTemplateUsage(
|
|
15
|
+
registry: Registry,
|
|
16
|
+
slug: string,
|
|
17
|
+
kind: TemplateKind,
|
|
18
|
+
): TemplateUsageEntry[] {
|
|
19
|
+
const usedBy: TemplateUsageEntry[] = [];
|
|
20
|
+
if (kind === "mail" || kind === "mail-layout") {
|
|
21
|
+
for (const [qualifiedName, notification] of registry.getAllNotifications()) {
|
|
22
|
+
const role: UsageRole = kind === "mail" ? "template" : "layout";
|
|
23
|
+
const isUsed =
|
|
24
|
+
kind === "mail"
|
|
25
|
+
? qualifiedName === slug
|
|
26
|
+
: [
|
|
27
|
+
...new Set([
|
|
28
|
+
...Object.keys(notification.template ?? {}),
|
|
29
|
+
...Object.keys(notification.meta ?? {}),
|
|
30
|
+
]),
|
|
31
|
+
].some(
|
|
32
|
+
(locale) =>
|
|
33
|
+
(notification.meta?.[locale]?.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG) === slug,
|
|
34
|
+
);
|
|
35
|
+
if (!isUsed) continue;
|
|
36
|
+
const parsed = parseQn(qualifiedName);
|
|
37
|
+
usedBy.push({
|
|
38
|
+
qualifiedName,
|
|
39
|
+
feature: parsed.scope,
|
|
40
|
+
notification: parsed.name,
|
|
41
|
+
trigger: notification.trigger?.on ?? null,
|
|
42
|
+
role,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
usedBy.sort((a, b) => a.qualifiedName.localeCompare(b.qualifiedName));
|
|
47
|
+
return usedBy;
|
|
48
|
+
}
|