@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/package.json +7 -5
  3. package/src/delivery/__tests__/delivery.integration.ts +6 -0
  4. package/src/delivery/delivery-service.ts +4 -12
  5. package/src/delivery/feature.ts +6 -4
  6. package/src/delivery/index.ts +0 -1
  7. package/src/legal-pages/web/client-plugin.ts +50 -10
  8. package/src/renderer-foundation/README.md +86 -0
  9. package/src/renderer-foundation/__tests__/api.test.ts +188 -0
  10. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
  11. package/src/renderer-foundation/api.ts +106 -0
  12. package/src/renderer-foundation/constants.ts +21 -0
  13. package/src/renderer-foundation/feature.ts +47 -0
  14. package/src/renderer-foundation/index.ts +25 -0
  15. package/src/renderer-foundation/types.ts +109 -0
  16. package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
  17. package/src/renderer-simple/feature.ts +28 -3
  18. package/src/template-resolver/README.md +89 -0
  19. package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
  20. package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
  21. package/src/template-resolver/api.ts +189 -0
  22. package/src/template-resolver/constants.ts +28 -0
  23. package/src/template-resolver/feature.ts +36 -0
  24. package/src/template-resolver/handlers/archive.write.ts +42 -0
  25. package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
  26. package/src/template-resolver/handlers/list.query.ts +69 -0
  27. package/src/template-resolver/handlers/publish.write.ts +45 -0
  28. package/src/template-resolver/handlers/shared.ts +41 -0
  29. package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
  30. package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
  31. package/src/template-resolver/index.ts +28 -0
  32. package/src/template-resolver/qualified-names.ts +24 -0
  33. package/src/template-resolver/table.ts +67 -0
  34. package/src/text-content/__tests__/text-content.integration.ts +54 -0
  35. package/src/text-content/handlers/by-slug.query.ts +1 -0
  36. package/src/text-content/handlers/by-tenant.query.ts +2 -0
  37. package/src/text-content/handlers/set.write.ts +23 -0
  38. package/src/text-content/seeding.ts +9 -1
  39. package/src/text-content/table.ts +6 -0
  40. package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
  41. package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
  42. package/src/text-content/web/client-plugin.tsx +378 -0
  43. package/src/text-content/web/client-plugin.ts +0 -113
@@ -0,0 +1,106 @@
1
+ // RendererFoundationApi — Cross-Feature-Schnittstelle. Konsumenten
2
+ // (delivery, mail-Sender, Solon NKA-PDF-Generator) holen sich pro
3
+ // Render-Call den passenden Plugin via `createRendererForTenant`.
4
+ // Pattern symmetrisch zu ai-foundation's `createLLMProviderForTenant`.
5
+
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { DEFAULT_PLUGIN_BY_KIND, type RenderKind } from "./constants";
9
+ import { RendererError, type RendererPlugin } from "./types";
10
+
11
+ export type RendererFoundationApi = {
12
+ /**
13
+ * Wählt + returnt ein RendererPlugin für (tenantId × kind). Caller
14
+ * rufen anschließend `plugin.render(req, ctx)`.
15
+ *
16
+ * **Auswahl-Reihenfolge:**
17
+ * 1. Tenant-spezifische Config (config-Bundle: `rendererPluginByKind`)
18
+ * 2. DEFAULT_PLUGIN_BY_KIND aus constants
19
+ * 3. Erstes Plugin im Pool das das kind bedient
20
+ * 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
21
+ *
22
+ * **Caller-Pattern:** `tenantId` MUSS vom Server kommen (typisch
23
+ * `ctx.user.tenantId`). Plugins, die Service-Access brauchen (z.B.
24
+ * `renderer-mail-html` für Layout-Resolve via `template-resolver`),
25
+ * erhalten `RendererContext` als zweites Argument zu `render()` —
26
+ * matcht das Pattern von `DeliveryChannel.send(addr, msg, ctx)`.
27
+ * Plugins ohne Service-Deps (z.B. `renderer-simple`) ignorieren ctx.
28
+ */
29
+ readonly createRendererForTenant: (args: {
30
+ readonly tenantId: TenantId;
31
+ readonly kind: RenderKind;
32
+ }) => RendererPlugin;
33
+ };
34
+
35
+ // Plugin-Pool wird zur Boot-Zeit aus Extension-Usages aufgebaut.
36
+ // Aus dem Registry kommen alle registrierten Plugins. Foundation-Feature
37
+ // baut den Pool via `collectRendererPlugins(registry)` und steckt das
38
+ // Result in extraContext.
39
+ export function createRendererFoundationApi(
40
+ plugins: ReadonlyArray<RendererPlugin>,
41
+ tenantConfigLookup: (tenantId: TenantId) => Record<string, string> | null = () => null,
42
+ ): RendererFoundationApi {
43
+ const byKind = indexByKind(plugins);
44
+ return {
45
+ createRendererForTenant: ({ tenantId, kind }) => {
46
+ const tenantConfig = tenantConfigLookup(tenantId) ?? {};
47
+
48
+ // 1. Tenant-Override
49
+ const tenantPluginName = tenantConfig[kind];
50
+ if (tenantPluginName) {
51
+ const plugin = plugins.find((p) => p.name === tenantPluginName && p.kinds.includes(kind));
52
+ if (plugin) return plugin;
53
+ // Tenant-Config zeigt auf nicht-registriertes Plugin — fällt durch zu Default
54
+ }
55
+
56
+ // 2. Default-Plugin
57
+ const defaultName = DEFAULT_PLUGIN_BY_KIND[kind];
58
+ if (defaultName) {
59
+ const plugin = plugins.find((p) => p.name === defaultName && p.kinds.includes(kind));
60
+ if (plugin) return plugin;
61
+ }
62
+
63
+ // 3. Erstes Plugin im Pool das das kind bedient
64
+ const first = byKind.get(kind)?.[0];
65
+ if (first) return first;
66
+
67
+ // 4. Kein Plugin → Error
68
+ throw new RendererError(
69
+ `[renderer-foundation] no plugin registered for kind="${kind}". Mount at least one plugin (renderer-simple, renderer-mail-html, renderer-puppeteer-client) for this kind.`,
70
+ "no_plugin_for_kind",
71
+ );
72
+ },
73
+ };
74
+ }
75
+
76
+ function indexByKind(plugins: ReadonlyArray<RendererPlugin>): Map<RenderKind, RendererPlugin[]> {
77
+ const map = new Map<RenderKind, RendererPlugin[]>();
78
+ for (const plugin of plugins) {
79
+ for (const kind of plugin.kinds) {
80
+ const list = map.get(kind) ?? [];
81
+ list.push(plugin);
82
+ map.set(kind, list);
83
+ }
84
+ }
85
+ return map;
86
+ }
87
+
88
+ // Single point of truth für "dieser Handler braucht renderer-foundation".
89
+ // Pattern symmetrisch zu requireTemplateResolver + requireTextContent.
90
+ export function requireRendererFoundation(
91
+ ctx: { readonly rendererFoundation?: RendererFoundationApi } | object,
92
+ callerName: string,
93
+ ): RendererFoundationApi {
94
+ // @cast-boundary engine-bridge — rendererFoundation kommt per extraContext
95
+ // aus App-Bootstrap.
96
+ const api = (ctx as { rendererFoundation?: RendererFoundationApi }).rendererFoundation;
97
+ if (!api) {
98
+ throw new InternalError({
99
+ message:
100
+ `[${callerName}] ctx.rendererFoundation missing — App-Bootstrap muss ` +
101
+ `extraContext: { rendererFoundation: createRendererFoundationApi(plugins) } setzen ` +
102
+ `(siehe renderer-foundation/README.md).`,
103
+ });
104
+ }
105
+ return api;
106
+ }
@@ -0,0 +1,21 @@
1
+ // Re-export aus template-resolver — gleiche RenderKind-Domäne. Beide
2
+ // Bundles teilen sich die Enum, damit Plugins (renderer-mail-html etc.)
3
+ // nicht zwei Quellen importieren müssen. Cross-feature-Imports gehen
4
+ // über das Barrel (../template-resolver), nicht via deep-import.
5
+ import type { RenderKind as RenderKindLocal } from "../template-resolver";
6
+
7
+ export {
8
+ CONTENT_FORMATS,
9
+ type ContentFormat,
10
+ RENDER_KINDS,
11
+ type RenderKind,
12
+ } from "../template-resolver";
13
+
14
+ // Standard-Default-Plugin pro Kind, wenn Tenant keine explizite Config
15
+ // gesetzt hat. App-Bootstrap kann das via TenantConfigKey überschreiben.
16
+ export const DEFAULT_PLUGIN_BY_KIND: Readonly<Record<RenderKindLocal, string>> = {
17
+ notification: "simple",
18
+ "mail-html": "mail-html",
19
+ "document-pdf": "puppeteer",
20
+ "image-snapshot": "puppeteer",
21
+ };
@@ -0,0 +1,47 @@
1
+ import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { RendererPlugin } from "./types";
3
+
4
+ // renderer-foundation — Plugin-Foundation für Renderer (Notification,
5
+ // HTML-Mail, PDF, Image). Plan-Doc:
6
+ // kumiko-platform/docs/plans/features/renderer-foundation.md
7
+ //
8
+ // Pattern symmetrisch zu ai-foundation: Foundation definiert den
9
+ // Extension-Point `renderer`, Plugins (renderer-simple, renderer-mail-html,
10
+ // renderer-puppeteer-client) registrieren sich via `r.useExtension`.
11
+ // Konsumenten holen sich Plugin runtime via createRendererForTenant.
12
+ export function createRendererFoundationFeature() {
13
+ return defineFeature("renderer-foundation", (r) => {
14
+ r.requires("template-resolver");
15
+
16
+ r.extendsRegistrar("renderer", {
17
+ onRegister: () => {
18
+ // Plugin-Konformitäts-Check könnte hier: shape-validation der
19
+ // options (kinds, render-Funktion present). Aktuell kein
20
+ // shape-check — Caller's TypeScript-Type-Sicherheit greift.
21
+ },
22
+ });
23
+
24
+ return {};
25
+ });
26
+ }
27
+
28
+ // Plugin-Pool-Aufbau zur Boot-Zeit. App-Bootstrap ruft das nach
29
+ // Feature-Registration auf, baut den Pool, gibt ihn via extraContext
30
+ // an die Handlers weiter.
31
+ //
32
+ // Symmetrisch zu collectChannels / collectRenderers in delivery-service.
33
+ export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
34
+ const usages = registry.getExtensionUsages("renderer");
35
+ return usages.map((usage) => {
36
+ // @cast-boundary engine-payload — extension-usage carries unknown options
37
+ const opts = usage.options as {
38
+ kinds: RendererPlugin["kinds"];
39
+ render: RendererPlugin["render"];
40
+ };
41
+ return {
42
+ name: usage.entityName,
43
+ kinds: opts.kinds,
44
+ render: opts.render,
45
+ };
46
+ });
47
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ createRendererFoundationApi,
3
+ type RendererFoundationApi,
4
+ requireRendererFoundation,
5
+ } from "./api";
6
+ export {
7
+ CONTENT_FORMATS,
8
+ type ContentFormat,
9
+ DEFAULT_PLUGIN_BY_KIND,
10
+ RENDER_KINDS,
11
+ type RenderKind,
12
+ } from "./constants";
13
+ export { collectRendererPlugins, createRendererFoundationFeature } from "./feature";
14
+ export {
15
+ type DocumentPayload,
16
+ type ImageOptions,
17
+ type MailHtmlPayload,
18
+ type NotificationPayload,
19
+ type PdfOptions,
20
+ type RendererContext,
21
+ RendererError,
22
+ type RendererPlugin,
23
+ type RenderRequest,
24
+ type RenderResponse,
25
+ } from "./types";
@@ -0,0 +1,109 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { ContentFormat, RenderKind } from "./constants";
4
+
5
+ // RenderRequest — Plugins erhalten das. Resource-Mode wählt der Caller
6
+ // (renderer-foundation api) je nach kind oder explizit. Plugin selbst
7
+ // entscheidet nicht — es bekommt eine schon aufgelöste Form.
8
+ export type RenderRequest =
9
+ | { kind: "notification"; payload: NotificationPayload }
10
+ | { kind: "mail-html"; payload: MailHtmlPayload }
11
+ | { kind: "document-pdf"; payload: DocumentPayload; options?: PdfOptions }
12
+ | { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
13
+
14
+ export type RenderResponse =
15
+ | { kind: "notification"; html: string }
16
+ | { kind: "mail-html"; html: string; text: string }
17
+ | { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
18
+ | {
19
+ kind: "image-snapshot";
20
+ imageBytes: Uint8Array;
21
+ format: "png" | "jpg";
22
+ width: number;
23
+ height: number;
24
+ };
25
+
26
+ export type NotificationPayload = {
27
+ readonly template?: string;
28
+ readonly content?: string;
29
+ readonly contentFormat?: ContentFormat;
30
+ readonly variables?: Readonly<Record<string, unknown>>;
31
+ readonly locale?: string;
32
+ };
33
+
34
+ export type MailHtmlPayload = {
35
+ readonly template?: string;
36
+ readonly content?: string;
37
+ readonly contentFormat?: ContentFormat;
38
+ readonly variables?: Readonly<Record<string, unknown>>;
39
+ readonly locale?: string;
40
+ readonly subject?: string;
41
+ };
42
+
43
+ export type DocumentPayload = {
44
+ readonly template?: string;
45
+ readonly content?: string;
46
+ readonly contentFormat?: ContentFormat;
47
+ readonly variables?: Readonly<Record<string, unknown>>;
48
+ readonly locale?: string;
49
+ };
50
+
51
+ export type PdfOptions = {
52
+ readonly format?: "A4" | "Letter";
53
+ readonly marginMm?: { top?: number; right?: number; bottom?: number; left?: number };
54
+ readonly headerTemplate?: string;
55
+ readonly footerTemplate?: string;
56
+ readonly printBackground?: boolean;
57
+ readonly displayHeaderFooter?: boolean;
58
+ };
59
+
60
+ export type ImageOptions = {
61
+ readonly width?: number;
62
+ readonly height?: number;
63
+ readonly format?: "png" | "jpg";
64
+ readonly quality?: number;
65
+ };
66
+
67
+ // RendererContext — schmale Surface (Kumiko-Style, matcht FileProviderContext
68
+ // + ChannelContext). Plugins, die Service-Access brauchen, holen sich
69
+ // templateResolver/etc. via direkten Bundle-Import + ctx.db (cross-feature-
70
+ // public-API). KEIN extraContext-Pass-Through — Plugin importiert pure-
71
+ // Function-Factories statt App-ctx zu casten.
72
+ //
73
+ // **Beispiel renderer-mail-html:**
74
+ // import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
75
+ // const tplApi = createTemplateResolverApi(ctx.db);
76
+ // const layout = await tplApi.resolveTemplate({ tenantId: ctx.tenantId, slug, kind });
77
+ export type RendererContext = {
78
+ readonly db: DbConnection;
79
+ readonly registry: Registry;
80
+ readonly tenantId: TenantId;
81
+ };
82
+
83
+ // RendererPlugin-Contract — pro Plugin (renderer-simple, renderer-mail-html,
84
+ // renderer-puppeteer-client). Plugin deklariert welche kinds es bedienen
85
+ // kann; Foundation-Resolver wählt basierend auf Tenant-Config + kind.
86
+ //
87
+ // **Plugin-Invarianten:**
88
+ // - `kinds` muss min. 1 Element haben (sonst nie ausgewählt)
89
+ // - `render(req, ctx)`-Response.kind MUSS req.kind matchen
90
+ // - Plugin ist zustandslos: kein internal-state zwischen render-Calls
91
+ // - Plugin wirft `RendererError` für domain-Fehler (nicht bare Error)
92
+ // - `ctx` ist required. Plugins ohne Service-Deps (z.B. renderer-simple)
93
+ // ignorieren ihn einfach — TS function-arg variance erlaubt `(req) => ...`-
94
+ // Implementations weiterhin (Implementation-Args ≤ Contract-Args ist OK).
95
+ export type RendererPlugin = {
96
+ readonly name: string;
97
+ readonly kinds: ReadonlyArray<RenderKind>;
98
+ render(req: RenderRequest, ctx: RendererContext): Promise<RenderResponse>;
99
+ };
100
+
101
+ export class RendererError extends Error {
102
+ constructor(
103
+ message: string,
104
+ public readonly code: "no_plugin_for_kind" | "invalid_payload" | "other",
105
+ ) {
106
+ super(message);
107
+ this.name = "RendererError";
108
+ }
109
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { RendererError } from "../../renderer-foundation";
3
+ import { adaptToFoundation } from "../feature";
4
+
5
+ describe("renderer-simple :: adaptToFoundation", () => {
6
+ test("kind='notification' rendert via simpleRenderer und gibt RenderResponse zurück", async () => {
7
+ const res = await adaptToFoundation({
8
+ kind: "notification",
9
+ payload: {
10
+ template: "welcome",
11
+ variables: { title: "Welcome!", body: "Hello there" },
12
+ },
13
+ });
14
+ expect(res.kind).toBe("notification");
15
+ if (res.kind === "notification") {
16
+ // simpleRenderer baut HTML mit title als header + body als section
17
+ expect(res.html).toContain("Welcome!");
18
+ expect(res.html).toContain("Hello there");
19
+ expect(res.html).toContain("<!DOCTYPE html>");
20
+ }
21
+ });
22
+
23
+ test("leere variables → leerer body, kein crash", async () => {
24
+ const res = await adaptToFoundation({
25
+ kind: "notification",
26
+ payload: { template: "", variables: {} },
27
+ });
28
+ expect(res.kind).toBe("notification");
29
+ });
30
+
31
+ test("non-notification kind → RendererError mit code 'invalid_payload'", async () => {
32
+ await expect(
33
+ adaptToFoundation({
34
+ kind: "mail-html",
35
+ payload: { content: "test", contentFormat: "markdown" },
36
+ }),
37
+ ).rejects.toThrow(RendererError);
38
+
39
+ try {
40
+ await adaptToFoundation({
41
+ kind: "document-pdf",
42
+ payload: { content: "test", contentFormat: "markdown" },
43
+ });
44
+ expect.fail("expected RendererError");
45
+ } catch (e) {
46
+ expect(e).toBeInstanceOf(RendererError);
47
+ expect((e as RendererError).code).toBe("invalid_payload");
48
+ }
49
+ });
50
+ });
@@ -1,12 +1,37 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { RendererError, type RenderRequest, type RenderResponse } from "../renderer-foundation";
2
3
  import { simpleRenderer } from "./simple-renderer";
3
4
 
5
+ // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
6
+ // NotificationRenderer-Contract), renderer-foundation erwartet
7
+ // `Promise<RenderResponse>` mit discriminated union. Mapper bewahrt
8
+ // die simpleRenderer-Implementierung (Template-Strings → HTML mit
9
+ // Inline-CSS) und packt sie in den RendererPlugin-Contract.
10
+ //
11
+ // Exported damit der Adapter-Pfad direkt testbar ist (unit-test).
12
+ export async function adaptToFoundation(req: RenderRequest): Promise<RenderResponse> {
13
+ if (req.kind !== "notification") {
14
+ // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
15
+ // dieser Pfad sollte unter normalen Umständen nie erreicht werden.
16
+ throw new RendererError(
17
+ `renderer-simple supports only kind="notification", got "${req.kind}"`,
18
+ "invalid_payload",
19
+ );
20
+ }
21
+ const html = await simpleRenderer.render({
22
+ template: req.payload.template ?? "",
23
+ variables: req.payload.variables ?? {},
24
+ });
25
+ return { kind: "notification", html };
26
+ }
27
+
4
28
  export function createRendererSimpleFeature(): FeatureDefinition {
5
29
  return defineFeature("rendererSimple", (r) => {
6
- r.requires("delivery");
30
+ r.requires("renderer-foundation");
7
31
 
8
- r.useExtension("notificationRenderer", "simple", {
9
- render: simpleRenderer.render,
32
+ r.useExtension("renderer", "simple", {
33
+ kinds: ["notification"] as const,
34
+ render: adaptToFoundation,
10
35
  });
11
36
  });
12
37
  }
@@ -0,0 +1,89 @@
1
+ # template-resolver
2
+
3
+ Strukturierter Template-Storage mit Tenant-Override-Hierarchie, Locale-Fallback und Resource-Linking via `file-foundation`.
4
+
5
+ **Plan-Doc:** [`kumiko-platform/docs/plans/features/template-resolver.md`](../../../../../../kumiko-platform/docs/plans/features/template-resolver.md)
6
+
7
+ **Status (2026-05-19):** 45 Integration-Tests grün, typecheck grün, self+advisor-reviewed. Implementierungs-Erkenntnisse im Plan-Doc.
8
+
9
+ ## Mount
10
+
11
+ ```typescript
12
+ // App-Bootstrap
13
+ import {
14
+ createTemplateResolverApi,
15
+ createTemplateResolverFeature,
16
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
17
+
18
+ const features = [
19
+ createTemplateResolverFeature(),
20
+ // ... weitere Features
21
+ ];
22
+
23
+ const app = createKumikoApp({
24
+ features,
25
+ extraContext: ({ db }) => ({
26
+ templateResolver: createTemplateResolverApi(db),
27
+ }),
28
+ });
29
+ ```
30
+
31
+ ## Konsumtion (in Feature-Handlern)
32
+
33
+ ```typescript
34
+ import { requireTemplateResolver } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
35
+
36
+ async function someHandler(ctx) {
37
+ const templateResolver = requireTemplateResolver(ctx, "someHandler");
38
+ const template = await templateResolver.resolveTemplate({
39
+ tenantId: ctx.user.tenantId,
40
+ slug: "nka-versand",
41
+ kind: "mail-html",
42
+ locale: "de",
43
+ });
44
+ // template.content + template.variableSchema + template.linkedResources verwenden
45
+ // ...
46
+ }
47
+ ```
48
+
49
+ ## Resolver-Reihenfolge (4-Stufen-Fallback)
50
+
51
+ 1. `tenantId` + requested locale
52
+ 2. `SYSTEM_TENANT_ID` + requested locale
53
+ 3. `tenantId` + `FALLBACK_LOCALE` (default "de")
54
+ 4. `SYSTEM_TENANT_ID` + `FALLBACK_LOCALE`
55
+
56
+ Wenn nichts gefunden → `TemplateNotFoundError`.
57
+
58
+ ## Admin-Workflows (Write-Handlers + Queries)
59
+
60
+ | Handler | QN | Wer | Was |
61
+ |---|---|---|---|
62
+ | `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Erstellt/Updated System-Default-Templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
63
+ | `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (eigener Tenant) + SystemAdmin via `tenantIdOverride` | Erstellt/Updated Tenant-Overrides (scope='tenant'), default-status='draft' |
64
+ | `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (eigener Tenant) | Setzt status='active' |
65
+ | `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (eigener Tenant) | Setzt status='archived' (Resolver ignoriert es danach) |
66
+ | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (eigener Tenant + system-templates sichtbar) | Raw-Lookup für Edit-UI |
67
+ | `TemplateResolverQueries.list` | `template-resolver:query:list` | gleich | Filter nach kind/locale/status, optional includeSystem |
68
+
69
+ **SystemAdmin-Cross-Tenant für publish/archive/findById:** aktuell nicht implementiert. `ctx.db` ist tenant-scoped (createTenantDb in dispatcher), SystemAdmin sieht ohne explicit `tenantIdOverride` keine fremden Tenants. Wenn Admin-UI das fordert: Schema-Erweiterung in einer M2-Iteration.
70
+
71
+ ## Status-Lifecycle
72
+
73
+ ```
74
+ upsertSystem ──┐
75
+ ├──► status: "active" (System-Default sofort aktiv)
76
+ upsertTenant ──┴──► status: "draft" (Default) | "active" (explizit)
77
+
78
+ publish ───────► status: "active"
79
+ archive ───────► status: "archived"
80
+ ```
81
+
82
+ Resolver returnt **nur** Templates mit `status: "active"`. draft/archived werden ignoriert.
83
+
84
+ ## Out-of-Scope
85
+
86
+ - Rendering (Markdown/MJML → HTML/PDF) — siehe `renderer-foundation`
87
+ - Resource-URL-Substitution (signed-URL vs. data-URI) — Caller-Verantwortung je nach kind
88
+ - Visual Template-Editor — `designer`-Bundle (geplant)
89
+ - A/B-Testing — eigenes Bundle wenn Bedarf real