@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
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Boot
|
|
2
|
-
// SYSTEM_TENANT_ID, scope=system, status=active)
|
|
3
|
-
//
|
|
1
|
+
// Boot/seed helper for app authors. Writes system templates (tenantId=
|
|
2
|
+
// SYSTEM_TENANT_ID, scope=system, status=active) idempotently — same projection
|
|
3
|
+
// path as the upsertSystem handler, without the access check.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// Use it from runProdApp seeds or TenantCreated hooks that install the welcome,
|
|
6
|
+
// incident or feature mail slugs, so the mail renderer can resolve them
|
|
7
|
+
// through the template-resolver.
|
|
8
8
|
//
|
|
9
|
-
// Default ifExists="skip". `createSystemUser(SYSTEM_TENANT_ID)
|
|
10
|
-
//
|
|
9
|
+
// Default ifExists="skip". The actor is `createSystemUser(SYSTEM_TENANT_ID)`,
|
|
10
|
+
// deliberately not TestUsers (prod seeds are not test utilities).
|
|
11
11
|
|
|
12
12
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
20
|
import {
|
|
21
21
|
createSystemUser,
|
|
22
|
+
type Registry,
|
|
22
23
|
type SessionUser,
|
|
23
24
|
SYSTEM_TENANT_ID,
|
|
24
25
|
type TenantId,
|
|
@@ -30,7 +31,12 @@ import {
|
|
|
30
31
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
31
32
|
import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants.js";
|
|
32
33
|
import { executor } from "./handlers/shared.js";
|
|
34
|
+
import { evaluateTemplateWrite } from "./handlers/template-gates.js";
|
|
35
|
+
import { TEMPLATE_RESOLVER_FEATURE } from "./qualified-names.js";
|
|
33
36
|
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
37
|
+
import type { TemplateBundle } from "./template-bundle.js";
|
|
38
|
+
import { templateSlugSchema } from "./template-meta.js";
|
|
39
|
+
import { variablesFromSchema } from "./template-validation.js";
|
|
34
40
|
|
|
35
41
|
/** Drop projection orphans (no events anywhere) so boot seeds can recreate. */
|
|
36
42
|
async function resolveExistingForEventStoreSeed(
|
|
@@ -65,7 +71,10 @@ export type SeedSystemTemplateOptions = {
|
|
|
65
71
|
readonly content: string;
|
|
66
72
|
readonly contentFormat: ContentFormat;
|
|
67
73
|
readonly variableSchema?: Record<string, unknown>;
|
|
74
|
+
/** Omitted: links on an existing row stay untouched, so a re-seed on boot keeps images a
|
|
75
|
+
* system admin uploaded through resource-upload. */
|
|
68
76
|
readonly linkedResources?: Record<string, string>;
|
|
77
|
+
readonly meta?: unknown;
|
|
69
78
|
readonly parentTemplateId?: string | null;
|
|
70
79
|
readonly by?: SessionUser;
|
|
71
80
|
readonly ifExists?: SeedIfExists;
|
|
@@ -75,10 +84,29 @@ export async function seedSystemTemplate(
|
|
|
75
84
|
db: DbConnection,
|
|
76
85
|
opts: SeedSystemTemplateOptions,
|
|
77
86
|
): Promise<{ id: string }> {
|
|
78
|
-
const tenantId = SYSTEM_TENANT_ID
|
|
87
|
+
const tenantId = SYSTEM_TENANT_ID;
|
|
79
88
|
const by = opts.by ?? createSystemUser(tenantId);
|
|
80
89
|
const tdb = createTenantDb(db, tenantId, "system");
|
|
81
90
|
|
|
91
|
+
const label = `seedSystemTemplate ${opts.slug}/${opts.kind}/${opts.locale}`;
|
|
92
|
+
const slug = templateSlugSchema.safeParse(opts.slug);
|
|
93
|
+
if (!slug.success) throw new Error(`${label} rejected: ${slug.error.issues[0]?.message}`);
|
|
94
|
+
const evaluated = evaluateTemplateWrite({
|
|
95
|
+
kind: opts.kind,
|
|
96
|
+
scope: "system",
|
|
97
|
+
contentFormat: opts.contentFormat,
|
|
98
|
+
content: opts.content,
|
|
99
|
+
rawMeta: opts.meta,
|
|
100
|
+
variables: variablesFromSchema(opts.variableSchema ?? {}),
|
|
101
|
+
resourceKeys: Object.keys(opts.linkedResources ?? {}),
|
|
102
|
+
});
|
|
103
|
+
const issues = evaluated.ok ? evaluated.publishIssues : evaluated.issues;
|
|
104
|
+
if (!evaluated.ok || issues.length > 0) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${label} rejected: ${issues.map((issue) => `${issue.code} (${issue.params?.["detail"] ?? issue.path})`).join("; ")}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
const existingRow = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
83
111
|
tenantId,
|
|
84
112
|
slug: opts.slug,
|
|
@@ -93,17 +121,20 @@ export async function seedSystemTemplate(
|
|
|
93
121
|
);
|
|
94
122
|
|
|
95
123
|
const variableSchema = JSON.stringify(opts.variableSchema ?? {});
|
|
96
|
-
const
|
|
124
|
+
const meta = evaluated.metaJson;
|
|
125
|
+
const linkedResources =
|
|
126
|
+
opts.linkedResources === undefined ? undefined : JSON.stringify(opts.linkedResources);
|
|
97
127
|
const parentTemplateId = opts.parentTemplateId ?? null;
|
|
98
128
|
|
|
99
129
|
const rowFields = {
|
|
100
130
|
slug: opts.slug,
|
|
101
131
|
kind: opts.kind,
|
|
102
132
|
locale: opts.locale,
|
|
103
|
-
content:
|
|
133
|
+
content: evaluated.content,
|
|
104
134
|
contentFormat: opts.contentFormat,
|
|
105
135
|
variableSchema,
|
|
106
|
-
linkedResources,
|
|
136
|
+
...(linkedResources !== undefined && { linkedResources }),
|
|
137
|
+
meta,
|
|
107
138
|
title: null,
|
|
108
139
|
folder: null,
|
|
109
140
|
scope: "system" as const,
|
|
@@ -111,7 +142,22 @@ export async function seedSystemTemplate(
|
|
|
111
142
|
status: "active" as const,
|
|
112
143
|
};
|
|
113
144
|
|
|
114
|
-
const applyUpdate = async (
|
|
145
|
+
const applyUpdate = async (
|
|
146
|
+
row: { id: string; version: number },
|
|
147
|
+
current: TemplateResourceRow | null | undefined,
|
|
148
|
+
): Promise<{ id: string }> => {
|
|
149
|
+
// Skip no-op updates so re-seeding on every pod boot does not bump
|
|
150
|
+
// version/modifiedAt or grow the event store.
|
|
151
|
+
if (
|
|
152
|
+
current?.content === rowFields.content &&
|
|
153
|
+
current?.contentFormat === rowFields.contentFormat &&
|
|
154
|
+
current?.title === rowFields.title &&
|
|
155
|
+
current?.variableSchema === rowFields.variableSchema &&
|
|
156
|
+
current?.meta === rowFields.meta &&
|
|
157
|
+
(linkedResources === undefined || current?.linkedResources === linkedResources)
|
|
158
|
+
) {
|
|
159
|
+
return { id: String(row.id) };
|
|
160
|
+
}
|
|
115
161
|
const result = await executor.update(
|
|
116
162
|
{ id: row.id, version: row.version, changes: rowFields },
|
|
117
163
|
by,
|
|
@@ -127,7 +173,11 @@ export async function seedSystemTemplate(
|
|
|
127
173
|
existing,
|
|
128
174
|
ifExists: opts.ifExists,
|
|
129
175
|
create: async () => {
|
|
130
|
-
const result = await executor.create(
|
|
176
|
+
const result = await executor.create(
|
|
177
|
+
{ ...rowFields, linkedResources: linkedResources ?? "{}", tenantId },
|
|
178
|
+
by,
|
|
179
|
+
tdb,
|
|
180
|
+
);
|
|
131
181
|
if (!result.isSuccess) {
|
|
132
182
|
// Parallel boot pods can both delete the orphan and race on create —
|
|
133
183
|
// unique on (tenantId, slug, kind, locale) → resolve winner and update.
|
|
@@ -152,7 +202,7 @@ export async function seedSystemTemplate(
|
|
|
152
202
|
existing: resolved,
|
|
153
203
|
ifExists: opts.ifExists,
|
|
154
204
|
create: () => Promise.reject(new Error("seedSystemTemplate: unreachable create")),
|
|
155
|
-
update: applyUpdate,
|
|
205
|
+
update: (row) => applyUpdate(row, again),
|
|
156
206
|
});
|
|
157
207
|
}
|
|
158
208
|
throw new Error(`seedSystemTemplate create failed: ${JSON.stringify(result)}`);
|
|
@@ -163,10 +213,90 @@ export async function seedSystemTemplate(
|
|
|
163
213
|
}
|
|
164
214
|
return { id: String(data.id) };
|
|
165
215
|
},
|
|
166
|
-
update: applyUpdate,
|
|
216
|
+
update: (row) => applyUpdate(row, existingRow),
|
|
167
217
|
});
|
|
168
218
|
}
|
|
169
219
|
|
|
220
|
+
export type SeedSystemTemplateBundleOptions = {
|
|
221
|
+
readonly by?: SessionUser;
|
|
222
|
+
readonly ifExists?: SeedIfExists;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Seeds one system row per bundle locale. Bundled resources need a file
|
|
226
|
+
// provider and the system tenant's file space, which a boot seed does not
|
|
227
|
+
// have, so those go through the import-bundle-system write instead.
|
|
228
|
+
export async function seedSystemTemplateBundle(
|
|
229
|
+
db: DbConnection,
|
|
230
|
+
bundle: TemplateBundle,
|
|
231
|
+
opts: SeedSystemTemplateBundleOptions = {},
|
|
232
|
+
): Promise<{ ids: Record<string, string> }> {
|
|
233
|
+
if (Object.keys(bundle.resources).length > 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`seedSystemTemplateBundle ${bundle.slug}/${bundle.kind}: bundles with resources cannot be seeded; use the import-bundle-system write`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const ids: Record<string, string> = {};
|
|
239
|
+
for (const [locale, entry] of Object.entries(bundle.locales)) {
|
|
240
|
+
const seeded = await seedSystemTemplate(db, {
|
|
241
|
+
slug: bundle.slug,
|
|
242
|
+
kind: bundle.kind,
|
|
243
|
+
locale,
|
|
244
|
+
content: entry.body,
|
|
245
|
+
contentFormat: bundle.contentFormat,
|
|
246
|
+
variableSchema: bundle.variableSchema ?? {},
|
|
247
|
+
meta: entry.meta,
|
|
248
|
+
...(opts.by !== undefined ? { by: opts.by } : {}),
|
|
249
|
+
...(opts.ifExists !== undefined ? { ifExists: opts.ifExists } : {}),
|
|
250
|
+
});
|
|
251
|
+
ids[locale] = seeded.id;
|
|
252
|
+
}
|
|
253
|
+
return { ids };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function hasNotificationTemplates(registry: Registry): boolean {
|
|
257
|
+
return Array.from(registry.getAllNotifications().values()).some(
|
|
258
|
+
(notification) => notification.template !== undefined,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Boot seed for notifications that ship their mail text with the code
|
|
263
|
+
// (`r.notification({ template, meta, variables })`). The code is authoritative,
|
|
264
|
+
// so existing system rows are updated; tenant overrides are untouched.
|
|
265
|
+
export async function seedNotificationTemplates(
|
|
266
|
+
db: DbConnection,
|
|
267
|
+
registry: Registry,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const hasTemplates = hasNotificationTemplates(registry);
|
|
270
|
+
if (hasTemplates && registry.getFeature(TEMPLATE_RESOLVER_FEATURE) === undefined) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Notifications declare a mail template but the "${TEMPLATE_RESOLVER_FEATURE}" feature is not mounted`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (hasTemplates) {
|
|
276
|
+
// Dynamic: renderer-foundation imports this feature's barrel, a static import would cycle.
|
|
277
|
+
const { collectRendererPlugins } = await import("../renderer-foundation/index.js");
|
|
278
|
+
if (!collectRendererPlugins(registry).some((plugin) => plugin.kinds.includes("mail"))) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
'Notifications declare a mail template but no renderer plugin for kind "mail" is registered (mount renderer-foundation and @cosmicdrift/kumiko-renderer-mail-html)',
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [slug, notification] of registry.getAllNotifications()) {
|
|
285
|
+
for (const [locale, { body }] of Object.entries(notification.template ?? {})) {
|
|
286
|
+
await seedSystemTemplate(db, {
|
|
287
|
+
slug,
|
|
288
|
+
kind: "mail",
|
|
289
|
+
locale,
|
|
290
|
+
content: body,
|
|
291
|
+
contentFormat: "html",
|
|
292
|
+
meta: notification.meta?.[locale],
|
|
293
|
+
variableSchema: notification.variables,
|
|
294
|
+
ifExists: "update",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
170
300
|
export type SeedTextBlockOptions = {
|
|
171
301
|
readonly tenantId: TenantId;
|
|
172
302
|
readonly slug: string;
|
|
@@ -306,7 +436,7 @@ export async function seedLegalContentFromJson(
|
|
|
306
436
|
blocks: readonly LegalContentBlock[],
|
|
307
437
|
opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
|
|
308
438
|
): Promise<void> {
|
|
309
|
-
const tenantId = opts.tenantId ??
|
|
439
|
+
const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
|
|
310
440
|
const by = opts.by ?? createSystemUser(tenantId);
|
|
311
441
|
for (const block of blocks) {
|
|
312
442
|
await seedTextBlock(db, {
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type {
|
|
3
|
+
ConfigAccessor,
|
|
4
|
+
HandlerContext,
|
|
5
|
+
Registry,
|
|
6
|
+
SessionUser,
|
|
7
|
+
TenantId,
|
|
8
|
+
WriteResult,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
NotFoundError,
|
|
12
|
+
UnprocessableError,
|
|
13
|
+
writeFailure,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
15
|
+
import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
|
|
16
|
+
import { fileContextForTenant } from "../file-foundation/index.js";
|
|
17
|
+
import type { FileAttachmentRef } from "../renderer-foundation/index.js";
|
|
18
|
+
import { requireForTenant } from "../shared/index.js";
|
|
19
|
+
import { SYSTEM_TENANT_ID } from "./constants.js";
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_RESOURCE_MAX_BYTES,
|
|
22
|
+
MAX_FILE_ATTACHMENTS_PER_TEMPLATE,
|
|
23
|
+
RESOURCE_MAX_BYTES_CONFIG_KEY,
|
|
24
|
+
} from "./handlers/resource-shared.js";
|
|
25
|
+
import { executor } from "./handlers/shared.js";
|
|
26
|
+
import {
|
|
27
|
+
attachmentEntriesOf,
|
|
28
|
+
type LinkedResourceEntry,
|
|
29
|
+
parseLinkedResourceEntries,
|
|
30
|
+
templatePdfAttachmentLinkSchema,
|
|
31
|
+
} from "./linked-resources.js";
|
|
32
|
+
import { TemplateResolverErrors } from "./qualified-names.js";
|
|
33
|
+
import { normalizeResourceImage } from "./resource-image.js";
|
|
34
|
+
import { type TemplateResourceRow, templateResourcesTable } from "./table.js";
|
|
35
|
+
|
|
36
|
+
const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
|
|
37
|
+
"image/png": "png",
|
|
38
|
+
"image/jpeg": "jpg",
|
|
39
|
+
"image/gif": "gif",
|
|
40
|
+
"image/webp": "webp",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export async function readTemplateResourceMaxBytes(
|
|
44
|
+
config: ConfigAccessor | undefined,
|
|
45
|
+
registry: Registry,
|
|
46
|
+
): Promise<number> {
|
|
47
|
+
if (config === undefined || registry.getConfigKey(RESOURCE_MAX_BYTES_CONFIG_KEY) === undefined) {
|
|
48
|
+
return DEFAULT_RESOURCE_MAX_BYTES;
|
|
49
|
+
}
|
|
50
|
+
const value = await config(RESOURCE_MAX_BYTES_CONFIG_KEY);
|
|
51
|
+
return typeof value === "number" ? value : DEFAULT_RESOURCE_MAX_BYTES;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type StoredTemplateResource = {
|
|
55
|
+
readonly fileRefId: string;
|
|
56
|
+
readonly key?: string;
|
|
57
|
+
readonly contentType: string;
|
|
58
|
+
readonly width: number;
|
|
59
|
+
readonly height: number;
|
|
60
|
+
readonly size: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Normalizes the image, stores it as a file ref of the target tenant and, with templateId + key,
|
|
64
|
+
// links it into that tenant's template. The caller is responsible for authorization; the function trusts tenantId.
|
|
65
|
+
export async function storeTemplateResource(args: {
|
|
66
|
+
readonly ctx: HandlerContext;
|
|
67
|
+
readonly user: SessionUser;
|
|
68
|
+
readonly tenantId: TenantId;
|
|
69
|
+
readonly bytes: Uint8Array;
|
|
70
|
+
readonly maxBytes: number;
|
|
71
|
+
readonly templateId?: string;
|
|
72
|
+
readonly key?: string;
|
|
73
|
+
}): Promise<WriteResult<StoredTemplateResource>> {
|
|
74
|
+
const { ctx, user, tenantId, bytes, maxBytes, templateId, key } = args;
|
|
75
|
+
if (bytes.length > maxBytes) {
|
|
76
|
+
return writeFailure(
|
|
77
|
+
new UnprocessableError(TemplateResolverErrors.resourceTooLarge, {
|
|
78
|
+
i18nKey: "templateResolver.errors.resource.tooLarge",
|
|
79
|
+
details: { maxBytes, size: bytes.length },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
let image: Awaited<ReturnType<typeof normalizeResourceImage>>;
|
|
84
|
+
try {
|
|
85
|
+
image = await normalizeResourceImage(Buffer.from(bytes));
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
return writeFailure(
|
|
88
|
+
new UnprocessableError(TemplateResolverErrors.resourceUnsupportedImage, {
|
|
89
|
+
i18nKey: "templateResolver.errors.resource.unsupportedImage",
|
|
90
|
+
cause: cause instanceof Error ? cause : undefined,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
96
|
+
let row: TemplateResourceRow | undefined;
|
|
97
|
+
if (templateId !== undefined) {
|
|
98
|
+
row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
99
|
+
tenantId,
|
|
100
|
+
id: templateId,
|
|
101
|
+
});
|
|
102
|
+
if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const created = await createFileRefFromBytes(
|
|
106
|
+
{
|
|
107
|
+
db,
|
|
108
|
+
files: fileContextForTenant(ctx, tenantId),
|
|
109
|
+
registry: ctx.registry,
|
|
110
|
+
user: { ...user, tenantId },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
// Neutral name: the source's file name may be personal data.
|
|
114
|
+
fileName: `${key ?? "resource"}.${EXTENSION_BY_CONTENT_TYPE[image.contentType] ?? "bin"}`,
|
|
115
|
+
mimeType: image.contentType,
|
|
116
|
+
data: image.data,
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
if (!created.isSuccess) return created;
|
|
120
|
+
|
|
121
|
+
if (row !== undefined && key !== undefined) {
|
|
122
|
+
// The replaced FileRef stays: copy-locale and tenant overrides can share ids and
|
|
123
|
+
// there is no reference count to tell when the last user is gone.
|
|
124
|
+
const linkedResources = {
|
|
125
|
+
...parseLinkedResourceEntries(row.linkedResources),
|
|
126
|
+
[key]: created.data.id,
|
|
127
|
+
};
|
|
128
|
+
const updated = await executor.update(
|
|
129
|
+
{
|
|
130
|
+
id: row.id,
|
|
131
|
+
version: row.version,
|
|
132
|
+
changes: { linkedResources: JSON.stringify(linkedResources) },
|
|
133
|
+
},
|
|
134
|
+
user,
|
|
135
|
+
db,
|
|
136
|
+
{ streamTenantId },
|
|
137
|
+
);
|
|
138
|
+
if (!updated.isSuccess) return updated;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
isSuccess: true as const,
|
|
143
|
+
data: {
|
|
144
|
+
fileRefId: created.data.id,
|
|
145
|
+
...(key !== undefined && { key }),
|
|
146
|
+
contentType: image.contentType,
|
|
147
|
+
width: image.width,
|
|
148
|
+
height: image.height,
|
|
149
|
+
size: image.data.length,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d] as const;
|
|
155
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
|
|
156
|
+
const JPEG_MAGIC = [0xff, 0xd8, 0xff] as const;
|
|
157
|
+
|
|
158
|
+
const ATTACHMENT_TYPES = [
|
|
159
|
+
{ contentType: "application/pdf", extension: "pdf", magic: PDF_MAGIC },
|
|
160
|
+
{ contentType: "image/png", extension: "png", magic: PNG_MAGIC },
|
|
161
|
+
{ contentType: "image/jpeg", extension: "jpg", magic: JPEG_MAGIC },
|
|
162
|
+
] as const;
|
|
163
|
+
|
|
164
|
+
function detectAttachmentType(bytes: Uint8Array) {
|
|
165
|
+
return ATTACHMENT_TYPES.find(({ magic }) => magic.every((byte, index) => bytes[index] === byte));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type StoredTemplateAttachment = {
|
|
169
|
+
readonly fileRefId: string;
|
|
170
|
+
readonly key: string;
|
|
171
|
+
readonly fileName: string;
|
|
172
|
+
readonly contentType: string;
|
|
173
|
+
readonly size: number;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
type AttachmentType = NonNullable<ReturnType<typeof detectAttachmentType>>;
|
|
177
|
+
|
|
178
|
+
export function attachmentNeedsMailTemplate(): WriteResult<never> {
|
|
179
|
+
return writeFailure(
|
|
180
|
+
new UnprocessableError(TemplateResolverErrors.attachmentInvalid, {
|
|
181
|
+
i18nKey: "templateResolver.errors.attachment.invalid",
|
|
182
|
+
details: { code: "mail_only" },
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function attachmentLimitFailure(): WriteResult<never> {
|
|
188
|
+
return writeFailure(
|
|
189
|
+
new UnprocessableError(TemplateResolverErrors.attachmentLimit, {
|
|
190
|
+
i18nKey: "templateResolver.errors.attachment.limit",
|
|
191
|
+
i18nParams: { max: MAX_FILE_ATTACHMENTS_PER_TEMPLATE },
|
|
192
|
+
details: { max: MAX_FILE_ATTACHMENTS_PER_TEMPLATE },
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Files and rendered PDFs share one cap; the entry being replaced under `key` does not count.
|
|
198
|
+
export function otherAttachmentCount(
|
|
199
|
+
linked: Readonly<Record<string, LinkedResourceEntry>>,
|
|
200
|
+
key: string,
|
|
201
|
+
): number {
|
|
202
|
+
return attachmentEntriesOf(linked).filter(([linkedKey]) => linkedKey !== key).length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function inspectAttachmentBytes(
|
|
206
|
+
bytes: Uint8Array,
|
|
207
|
+
maxBytes: number,
|
|
208
|
+
): { readonly failure: WriteResult<never> } | { readonly type: AttachmentType } {
|
|
209
|
+
if (bytes.length > maxBytes) {
|
|
210
|
+
return {
|
|
211
|
+
failure: writeFailure(
|
|
212
|
+
new UnprocessableError(TemplateResolverErrors.resourceTooLarge, {
|
|
213
|
+
details: { maxBytes, size: bytes.length },
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const type = detectAttachmentType(bytes);
|
|
219
|
+
if (type === undefined) {
|
|
220
|
+
return {
|
|
221
|
+
failure: writeFailure(
|
|
222
|
+
new UnprocessableError(TemplateResolverErrors.attachmentUnsupportedType, {
|
|
223
|
+
i18nKey: "templateResolver.errors.attachment.unsupportedType",
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { type };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Stores the bytes unchanged as a file of the target tenant, without linking it anywhere.
|
|
232
|
+
async function createAttachmentFileRef(args: {
|
|
233
|
+
readonly ctx: HandlerContext;
|
|
234
|
+
readonly user: SessionUser;
|
|
235
|
+
readonly tenantId: TenantId;
|
|
236
|
+
readonly bytes: Uint8Array;
|
|
237
|
+
readonly key: string;
|
|
238
|
+
readonly type: AttachmentType;
|
|
239
|
+
}): Promise<WriteResult<{ readonly id: string }>> {
|
|
240
|
+
const { ctx, user, tenantId, bytes, key, type } = args;
|
|
241
|
+
const { db } = requireForTenant(ctx, tenantId);
|
|
242
|
+
const created = await createFileRefFromBytes(
|
|
243
|
+
{
|
|
244
|
+
db,
|
|
245
|
+
files: fileContextForTenant(ctx, tenantId),
|
|
246
|
+
registry: ctx.registry,
|
|
247
|
+
user: { ...user, tenantId },
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
// Neutral stored name: the admin's file name may be personal data and lives in the link entry.
|
|
251
|
+
fileName: `${key}.${type.extension}`,
|
|
252
|
+
mimeType: type.contentType,
|
|
253
|
+
data: bytes,
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
return created.isSuccess ? { isSuccess: true, data: { id: created.data.id } } : created;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Stores a PDF/PNG/JPEG for a bundle import: validated like an upload, linked later by the importer.
|
|
260
|
+
export async function storeBundleAttachment(args: {
|
|
261
|
+
readonly ctx: HandlerContext;
|
|
262
|
+
readonly user: SessionUser;
|
|
263
|
+
readonly tenantId: TenantId;
|
|
264
|
+
readonly bytes: Uint8Array;
|
|
265
|
+
readonly maxBytes: number;
|
|
266
|
+
readonly key: string;
|
|
267
|
+
readonly fileName: string;
|
|
268
|
+
}): Promise<WriteResult<FileAttachmentRef>> {
|
|
269
|
+
const inspected = inspectAttachmentBytes(args.bytes, args.maxBytes);
|
|
270
|
+
if ("failure" in inspected) return inspected.failure;
|
|
271
|
+
const created = await createAttachmentFileRef({ ...args, type: inspected.type });
|
|
272
|
+
if (!created.isSuccess) return created;
|
|
273
|
+
return {
|
|
274
|
+
isSuccess: true,
|
|
275
|
+
data: { kind: "file", fileRefId: created.data.id, fileName: args.fileName },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Stores a PDF/PNG/JPEG byte-for-byte (no re-encoding, the recipient gets what the admin uploaded)
|
|
280
|
+
// and links it under `key` in a mail template of the target tenant. The caller authorizes tenantId.
|
|
281
|
+
export async function storeTemplateAttachment(args: {
|
|
282
|
+
readonly ctx: HandlerContext;
|
|
283
|
+
readonly user: SessionUser;
|
|
284
|
+
readonly tenantId: TenantId;
|
|
285
|
+
readonly bytes: Uint8Array;
|
|
286
|
+
readonly maxBytes: number;
|
|
287
|
+
readonly templateId: string;
|
|
288
|
+
readonly key: string;
|
|
289
|
+
readonly fileName: string;
|
|
290
|
+
}): Promise<WriteResult<StoredTemplateAttachment>> {
|
|
291
|
+
const { ctx, user, tenantId, bytes, maxBytes, templateId, key, fileName } = args;
|
|
292
|
+
const inspected = inspectAttachmentBytes(bytes, maxBytes);
|
|
293
|
+
if ("failure" in inspected) return inspected.failure;
|
|
294
|
+
const { type } = inspected;
|
|
295
|
+
|
|
296
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
297
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
298
|
+
tenantId,
|
|
299
|
+
id: templateId,
|
|
300
|
+
});
|
|
301
|
+
if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
|
|
302
|
+
if (row.kind !== "mail") return attachmentNeedsMailTemplate();
|
|
303
|
+
|
|
304
|
+
const linked = parseLinkedResourceEntries(row.linkedResources);
|
|
305
|
+
if (otherAttachmentCount(linked, key) >= MAX_FILE_ATTACHMENTS_PER_TEMPLATE) {
|
|
306
|
+
return attachmentLimitFailure();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const created = await createAttachmentFileRef({ ctx, user, tenantId, bytes, key, type });
|
|
310
|
+
if (!created.isSuccess) return created;
|
|
311
|
+
|
|
312
|
+
const entry: LinkedResourceEntry = { kind: "file", fileRefId: created.data.id, fileName };
|
|
313
|
+
const updated = await executor.update(
|
|
314
|
+
{
|
|
315
|
+
id: row.id,
|
|
316
|
+
version: row.version,
|
|
317
|
+
changes: { linkedResources: JSON.stringify({ ...linked, [key]: entry }) },
|
|
318
|
+
},
|
|
319
|
+
user,
|
|
320
|
+
db,
|
|
321
|
+
{ streamTenantId },
|
|
322
|
+
);
|
|
323
|
+
if (!updated.isSuccess) return updated;
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
isSuccess: true as const,
|
|
327
|
+
data: {
|
|
328
|
+
fileRefId: created.data.id,
|
|
329
|
+
key,
|
|
330
|
+
fileName,
|
|
331
|
+
contentType: type.contentType,
|
|
332
|
+
size: bytes.length,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export type LinkedTemplatePdfAttachment = {
|
|
338
|
+
readonly key: string;
|
|
339
|
+
readonly templateSlug: string;
|
|
340
|
+
readonly fileName: string;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
function pdfAttachmentInvalid(code: string): WriteResult<never> {
|
|
344
|
+
return writeFailure(
|
|
345
|
+
new UnprocessableError(TemplateResolverErrors.attachmentInvalid, {
|
|
346
|
+
i18nKey: "templateResolver.errors.attachment.invalid",
|
|
347
|
+
details: { code },
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Links a PDF that is rendered from a document template at send time into a mail template of the
|
|
353
|
+
// target tenant. Nothing is stored besides the link entry. The caller authorizes tenantId.
|
|
354
|
+
export async function linkTemplatePdfAttachment(args: {
|
|
355
|
+
readonly ctx: HandlerContext;
|
|
356
|
+
readonly user: SessionUser;
|
|
357
|
+
readonly tenantId: TenantId;
|
|
358
|
+
readonly templateId: string;
|
|
359
|
+
readonly key: string;
|
|
360
|
+
readonly templateSlug: string;
|
|
361
|
+
readonly fileName: string;
|
|
362
|
+
}): Promise<WriteResult<LinkedTemplatePdfAttachment>> {
|
|
363
|
+
const { ctx, user, tenantId, templateId, key } = args;
|
|
364
|
+
const link = templatePdfAttachmentLinkSchema.safeParse({
|
|
365
|
+
kind: "rendered-pdf",
|
|
366
|
+
templateSlug: args.templateSlug,
|
|
367
|
+
fileName: args.fileName,
|
|
368
|
+
});
|
|
369
|
+
if (!link.success) return pdfAttachmentInvalid("invalid_pdf_link");
|
|
370
|
+
|
|
371
|
+
const { db, streamTenantId } = requireForTenant(ctx, tenantId);
|
|
372
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
373
|
+
tenantId,
|
|
374
|
+
id: templateId,
|
|
375
|
+
});
|
|
376
|
+
if (row === undefined) return writeFailure(new NotFoundError("template-resource", templateId));
|
|
377
|
+
if (row.kind !== "mail") return attachmentNeedsMailTemplate();
|
|
378
|
+
|
|
379
|
+
const linked = parseLinkedResourceEntries(row.linkedResources);
|
|
380
|
+
if (otherAttachmentCount(linked, key) >= MAX_FILE_ATTACHMENTS_PER_TEMPLATE) {
|
|
381
|
+
return attachmentLimitFailure();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const visibleTemplates = await selectMany<TemplateResourceRow>(
|
|
385
|
+
db,
|
|
386
|
+
templateResourcesTable,
|
|
387
|
+
{ tenantId: [tenantId, SYSTEM_TENANT_ID], slug: link.data.templateSlug, kind: "document-pdf" },
|
|
388
|
+
{ limit: 1 },
|
|
389
|
+
);
|
|
390
|
+
if (visibleTemplates.length === 0) {
|
|
391
|
+
return writeFailure(
|
|
392
|
+
new UnprocessableError(TemplateResolverErrors.attachmentPdfTemplateMissing, {
|
|
393
|
+
i18nKey: "templateResolver.errors.attachment.pdfTemplateMissing",
|
|
394
|
+
i18nParams: { slug: link.data.templateSlug },
|
|
395
|
+
details: { templateSlug: link.data.templateSlug },
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const updated = await executor.update(
|
|
401
|
+
{
|
|
402
|
+
id: row.id,
|
|
403
|
+
version: row.version,
|
|
404
|
+
changes: { linkedResources: JSON.stringify({ ...linked, [key]: link.data }) },
|
|
405
|
+
},
|
|
406
|
+
user,
|
|
407
|
+
db,
|
|
408
|
+
{ streamTenantId },
|
|
409
|
+
);
|
|
410
|
+
if (!updated.isSuccess) return updated;
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
isSuccess: true as const,
|
|
414
|
+
data: { key, templateSlug: link.data.templateSlug, fileName: link.data.fileName },
|
|
415
|
+
};
|
|
416
|
+
}
|