@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,189 @@
1
+ // TemplateResolverApi — Cross-Feature-Schnittstelle. renderer-foundation
2
+ // + delivery + Apps importieren NUR Types und holen die Implementation
3
+ // runtime aus ctx.templateResolver. Pattern symmetrisch zu textContent
4
+ // (siehe text-content/api.ts).
5
+
6
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
+ import { and, eq } from "drizzle-orm";
10
+ import type { ContentFormat, RenderKind } from "./constants";
11
+ import { FALLBACK_LOCALE, SYSTEM_TENANT_ID } from "./constants";
12
+ import { type TemplateResourceRow, templateResourcesTable } from "./table";
13
+
14
+ // Public TemplateResource — was Konsumenten sehen. Versteckt DB-interne
15
+ // Spalten (createdBy, internal id-type), behält Felder die zum Rendern
16
+ // gebraucht werden.
17
+ export type TemplateResource = {
18
+ readonly id: string;
19
+ readonly version: number;
20
+ readonly tenantId: string;
21
+ readonly slug: string;
22
+ readonly kind: RenderKind;
23
+ readonly locale: string;
24
+ readonly content: string;
25
+ readonly contentFormat: ContentFormat;
26
+ readonly variableSchema: Record<string, unknown>;
27
+ readonly linkedResources: Record<string, string>;
28
+ readonly scope: "system" | "tenant";
29
+ readonly parentTemplateId: string | null;
30
+ readonly status: "draft" | "active" | "archived";
31
+ readonly updatedAt: Date;
32
+ };
33
+
34
+ export type ResolveRequest = {
35
+ readonly tenantId: TenantId;
36
+ readonly slug: string;
37
+ readonly kind: RenderKind;
38
+ readonly locale: string;
39
+ };
40
+
41
+ export type TemplateResolverApi = {
42
+ /**
43
+ * Findet ein konkretes Template by (tenantId, slug, kind, locale).
44
+ * Kein Locale-Fallback, keine Override-Hierarchie — Raw-Lookup für
45
+ * Admin-UI-Edits und Konformitäts-Tests. Für Render-Aufrufe immer
46
+ * `resolveTemplate` nutzen.
47
+ *
48
+ * `scope: "system"` zwingt Lookup gegen SYSTEM_TENANT_ID (ignoriert
49
+ * den übergebenen tenantId). Default = Lookup gegen den übergebenen
50
+ * tenantId direkt (Caller wählt entweder Tenant oder System per
51
+ * tenantId).
52
+ */
53
+ readonly findExact: (args: {
54
+ readonly tenantId: TenantId;
55
+ readonly slug: string;
56
+ readonly kind: RenderKind;
57
+ readonly locale: string;
58
+ readonly scope?: "system";
59
+ }) => Promise<TemplateResource | null>;
60
+
61
+ /**
62
+ * Resolver mit 4-Stufen-Fallback (siehe template-resolver.md):
63
+ * 1. tenant + requested-locale
64
+ * 2. system + requested-locale
65
+ * 3. tenant + FALLBACK_LOCALE
66
+ * 4. system + FALLBACK_LOCALE
67
+ * Resource-Substitution wird hier NICHT ausgeführt — das macht der
68
+ * Caller (renderer-foundation-Plugin) je nach kind-passendem Modus
69
+ * (inline-base64 vs. signed-url). API liefert TemplateResource pur.
70
+ *
71
+ * **Caller-Invariante:** `tenantId` MUSS vom Server kommen (typisch
72
+ * `ctx.user.tenantId`). Niemals direkt aus User-Input — sonst kann
73
+ * User cross-tenant Templates abfragen (Tenant-Isolation gebrochen).
74
+ */
75
+ readonly resolveTemplate: (args: ResolveRequest) => Promise<TemplateResource>;
76
+ };
77
+
78
+ export function createTemplateResolverApi(db: DbConnection): TemplateResolverApi {
79
+ return {
80
+ findExact: async ({ tenantId, slug, kind, locale, scope }) => {
81
+ const effectiveTenantId = scope === "system" ? SYSTEM_TENANT_ID : tenantId;
82
+ const row = await fetchTemplate(db, effectiveTenantId, slug, kind, locale);
83
+ return row ? toPublic(row) : null;
84
+ },
85
+
86
+ resolveTemplate: async ({ tenantId, slug, kind, locale }) => {
87
+ // Fallback-Chain — finde erste passende Variante
88
+ const candidates: ReadonlyArray<{ tid: string; loc: string }> = [
89
+ { tid: tenantId, loc: locale },
90
+ { tid: SYSTEM_TENANT_ID, loc: locale },
91
+ ...(locale !== FALLBACK_LOCALE
92
+ ? [
93
+ { tid: tenantId, loc: FALLBACK_LOCALE },
94
+ { tid: SYSTEM_TENANT_ID, loc: FALLBACK_LOCALE },
95
+ ]
96
+ : []),
97
+ ];
98
+ for (const c of candidates) {
99
+ const row = await fetchTemplate(db, c.tid, slug, kind, c.loc);
100
+ if (row && row.status === "active") return toPublic(row);
101
+ }
102
+ throw new TemplateNotFoundError({ slug, kind, locale });
103
+ },
104
+ };
105
+ }
106
+
107
+ async function fetchTemplate(
108
+ db: DbConnection,
109
+ tenantId: string,
110
+ slug: string,
111
+ kind: RenderKind,
112
+ locale: string,
113
+ ): Promise<TemplateResourceRow | null> {
114
+ const rows = await db
115
+ .select()
116
+ .from(templateResourcesTable)
117
+ .where(
118
+ and(
119
+ eq(templateResourcesTable["tenantId"], tenantId),
120
+ eq(templateResourcesTable["slug"], slug),
121
+ eq(templateResourcesTable["kind"], kind),
122
+ eq(templateResourcesTable["locale"], locale),
123
+ ),
124
+ )
125
+ .limit(1);
126
+ // @cast-boundary db-row — db.select returnt unbenanntes unknown[],
127
+ // Row-Shape ist via templateResourcesTable + buildBaseColumns garantiert.
128
+ return (rows[0] as TemplateResourceRow | undefined) ?? null;
129
+ }
130
+
131
+ function toPublic(row: TemplateResourceRow): TemplateResource {
132
+ return {
133
+ id: String(row.id),
134
+ version: row.version,
135
+ tenantId: row.tenantId,
136
+ slug: row.slug,
137
+ kind: row.kind as RenderKind,
138
+ locale: row.locale,
139
+ content: row.content ?? "",
140
+ contentFormat: row.contentFormat as ContentFormat,
141
+ variableSchema: parseJson(row.variableSchema),
142
+ linkedResources: parseJson(row.linkedResources) as Record<string, string>,
143
+ scope: row.scope as "system" | "tenant",
144
+ parentTemplateId: row.parentTemplateId,
145
+ status: row.status as "draft" | "active" | "archived",
146
+ updatedAt: row.updatedAt,
147
+ };
148
+ }
149
+
150
+ function parseJson(raw: string | null): Record<string, unknown> {
151
+ if (!raw) return {};
152
+ try {
153
+ const parsed = JSON.parse(raw);
154
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
155
+ } catch {
156
+ return {};
157
+ }
158
+ }
159
+
160
+ export class TemplateNotFoundError extends Error {
161
+ constructor(public readonly query: { slug: string; kind: RenderKind; locale: string }) {
162
+ super(
163
+ `[template-resolver] no template found for slug="${query.slug}" kind="${query.kind}" locale="${query.locale}" (checked tenant + system, requested + fallback locale)`,
164
+ );
165
+ this.name = "TemplateNotFoundError";
166
+ }
167
+ }
168
+
169
+ // Single point of truth für "dieser Handler braucht template-resolver".
170
+ // Pattern symmetrisch zu requireTextContent.
171
+ export function requireTemplateResolver(
172
+ ctx: { readonly templateResolver?: TemplateResolverApi } | object,
173
+ callerName: string,
174
+ ): TemplateResolverApi {
175
+ // @cast-boundary engine-bridge — templateResolver kommt per extraContext
176
+ // aus App-Bootstrap, Framework-Container kennt das Feld nicht von sich aus.
177
+ const api = (ctx as { templateResolver?: TemplateResolverApi }).templateResolver;
178
+ if (!api) {
179
+ throw new InternalError({
180
+ message:
181
+ `[${callerName}] ctx.templateResolver missing — App-Bootstrap muss ` +
182
+ `extraContext: { templateResolver: createTemplateResolverApi(db) } setzen ` +
183
+ `(siehe template-resolver/README.md).`,
184
+ });
185
+ }
186
+ return api;
187
+ }
188
+
189
+ export type { SessionUser };
@@ -0,0 +1,28 @@
1
+ // RenderKind identifiziert die Konsumenten-Klasse eines Templates.
2
+ // Plugin-Renderer in `renderer-foundation` matchen auf kind; der
3
+ // Resolver hier ist kind-agnostisch — er lädt nur, das Content-Format
4
+ // (markdown/mjml/html) entscheidet wer's rendert.
5
+ export const RENDER_KINDS = [
6
+ "notification",
7
+ "mail-html",
8
+ "document-pdf",
9
+ "image-snapshot",
10
+ ] as const;
11
+ export type RenderKind = (typeof RENDER_KINDS)[number];
12
+
13
+ export const CONTENT_FORMATS = ["markdown", "mjml", "html", "plain"] as const;
14
+ export type ContentFormat = (typeof CONTENT_FORMATS)[number];
15
+
16
+ export const TEMPLATE_SCOPES = ["system", "tenant"] as const;
17
+ export type TemplateScope = (typeof TEMPLATE_SCOPES)[number];
18
+
19
+ export const TEMPLATE_STATUSES = ["draft", "active", "archived"] as const;
20
+ export type TemplateStatus = (typeof TEMPLATE_STATUSES)[number];
21
+
22
+ // System-Templates leben unter der canonical SYSTEM_TENANT_ID-Sentinel-UUID.
23
+ // Re-Export aus framework — single source of truth, vermeidet Drift.
24
+ export { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
25
+
26
+ // Default-Locale wenn Tenant keinen eigenen Default konfiguriert. Resolver
27
+ // fällt darauf zurück wenn requested locale + tenant-default fehlen.
28
+ export const FALLBACK_LOCALE = "de";
@@ -0,0 +1,36 @@
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { archiveWrite } from "./handlers/archive.write";
3
+ import { findByIdQuery } from "./handlers/find-by-id.query";
4
+ import { listQuery } from "./handlers/list.query";
5
+ import { publishWrite } from "./handlers/publish.write";
6
+ import { upsertSystemWrite } from "./handlers/upsert-system.write";
7
+ import { upsertTenantWrite } from "./handlers/upsert-tenant.write";
8
+ import { templateResourceEntity } from "./table";
9
+
10
+ // template-resolver — strukturierter Template-Storage mit Tenant-
11
+ // Override-Hierarchie, Locale-Fallback und Resource-Linking via
12
+ // file-foundation. Plan-Doc: kumiko-platform/docs/plans/features/template-resolver.md
13
+ //
14
+ // Konsumtions-Pfade:
15
+ // - Render-Time: ctx.templateResolver.resolveTemplate(...) (siehe api.ts)
16
+ // - Admin-UI: write/query-handlers (upsertSystem, upsertTenant, publish, archive, findById, list)
17
+ // - Cross-Feature: requireTemplateResolver(ctx, callerName) — Pattern wie requireTextContent
18
+ export function createTemplateResolverFeature() {
19
+ return defineFeature("template-resolver", (r) => {
20
+ r.entity("template-resource", templateResourceEntity);
21
+
22
+ const handlers = {
23
+ upsertSystem: r.writeHandler(upsertSystemWrite),
24
+ upsertTenant: r.writeHandler(upsertTenantWrite),
25
+ publish: r.writeHandler(publishWrite),
26
+ archive: r.writeHandler(archiveWrite),
27
+ };
28
+
29
+ const queries = {
30
+ findById: r.queryHandler(findByIdQuery),
31
+ list: r.queryHandler(listQuery),
32
+ };
33
+
34
+ return { handlers, queries };
35
+ });
36
+ }
@@ -0,0 +1,42 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import type { TemplateResourceRow } from "../table";
7
+ import { templateResourcesTable } from "../table";
8
+ import { executor } from "./shared";
9
+
10
+ // Setzt einen Template-Eintrag auf status='archived'. Resolver liefert
11
+ // archivierte Templates nicht zurück — sie bleiben aber als Audit-Trail
12
+ // in der DB (kein physisches Delete). Reaktivierung via publish.
13
+ export const archiveWrite = defineWriteHandler({
14
+ name: "archive",
15
+ schema: z.object({ id: z.string().min(1) }),
16
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
17
+ handler: async (event, ctx) => {
18
+ const existing = await fetchOne<TemplateResourceRow>(
19
+ ctx.db,
20
+ templateResourcesTable,
21
+ eq(templateResourcesTable["id"], event.payload.id),
22
+ );
23
+ // ctx.db ist via createTenantDb tenant-scoped — existing ist null wenn
24
+ // das Template einem fremden Tenant gehört (SystemAdmin-Cross-Tenant
25
+ // braucht tenantIdOverride im Schema, M2-Erweiterung).
26
+ if (!existing) {
27
+ return writeFailure(new NotFoundError("template-resource", event.payload.id));
28
+ }
29
+
30
+ const result = await executor.update(
31
+ {
32
+ id: existing.id,
33
+ version: existing.version,
34
+ changes: { status: "archived" as const },
35
+ },
36
+ event.user,
37
+ ctx.db,
38
+ );
39
+ if (!result.isSuccess) return result;
40
+ return { isSuccess: true as const, data: { id: String(existing.id), status: "archived" } };
41
+ },
42
+ });
@@ -0,0 +1,45 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
6
+
7
+ // Admin-Lookup für UI-Edit-Flow. Returnt das raw Template inkl. draft/
8
+ // archived Status. Tenant-Isolation: User sieht nur Templates des
9
+ // eigenen Tenants oder SYSTEM_TENANT (system-defaults sind public-
10
+ // readable). SystemAdmin sieht alle. Anonymous bleibt aus — Public-
11
+ // Read geht über resolveTemplate aus der API, nicht hier.
12
+ export const findByIdQuery = defineQueryHandler({
13
+ name: "find-by-id",
14
+ schema: z.object({ id: z.string().min(1) }),
15
+ access: { roles: ["TenantAdmin", "SystemAdmin", "User"] },
16
+ handler: async (query, ctx) => {
17
+ const row = await fetchOne<TemplateResourceRow>(
18
+ ctx.db,
19
+ templateResourcesTable,
20
+ eq(templateResourcesTable["id"], query.payload.id),
21
+ );
22
+ if (!row) return null;
23
+ const isSystemAdmin = query.user.roles.includes("SystemAdmin");
24
+ const isOwnTenant = row.tenantId === query.user.tenantId;
25
+ const isSystemTemplate = row.scope === "system";
26
+ if (!isSystemAdmin && !isOwnTenant && !isSystemTemplate) return null;
27
+
28
+ return {
29
+ id: String(row.id),
30
+ version: row.version,
31
+ tenantId: row.tenantId,
32
+ slug: row.slug,
33
+ kind: row.kind,
34
+ locale: row.locale,
35
+ content: row.content,
36
+ contentFormat: row.contentFormat,
37
+ variableSchema: row.variableSchema,
38
+ linkedResources: row.linkedResources,
39
+ scope: row.scope,
40
+ parentTemplateId: row.parentTemplateId,
41
+ status: row.status,
42
+ updatedAt: row.updatedAt,
43
+ };
44
+ },
45
+ });
@@ -0,0 +1,69 @@
1
+ import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, eq, or, type SQL } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { RENDER_KINDS, TEMPLATE_STATUSES } from "../constants";
5
+ import { type TemplateResourceRow, templateResourcesTable } from "../table";
6
+
7
+ // List für Admin-UI: filterbar nach kind / locale / status. Liefert
8
+ // system-templates + tenant's eigene zusammen — Admin-UI rendert beide
9
+ // Spalten mit scope-marker. SystemAdmin sieht alle (auch andere Tenants).
10
+ export const listQuery = defineQueryHandler({
11
+ name: "list",
12
+ schema: z.object({
13
+ kind: z.enum(RENDER_KINDS).optional(),
14
+ locale: z.string().min(2).max(8).optional(),
15
+ status: z.enum(TEMPLATE_STATUSES).optional(),
16
+ includeSystem: z.boolean().default(true),
17
+ }),
18
+ access: { roles: ["TenantAdmin", "SystemAdmin", "User"] },
19
+ handler: async (query, ctx) => {
20
+ const isSystemAdmin = query.user.roles.includes("SystemAdmin");
21
+ const conditions: SQL<unknown>[] = [];
22
+
23
+ // Tenant-Scope: SystemAdmin sieht alles, andere nur eigener Tenant + System
24
+ if (!isSystemAdmin) {
25
+ if (query.payload.includeSystem) {
26
+ const scopeCondition = or(
27
+ eq(templateResourcesTable["tenantId"], query.user.tenantId),
28
+ eq(templateResourcesTable["tenantId"], SYSTEM_TENANT_ID),
29
+ );
30
+ if (scopeCondition) conditions.push(scopeCondition);
31
+ } else {
32
+ conditions.push(eq(templateResourcesTable["tenantId"], query.user.tenantId));
33
+ }
34
+ } else if (!query.payload.includeSystem) {
35
+ // SystemAdmin mit includeSystem=false → noch eigener Tenant only
36
+ conditions.push(eq(templateResourcesTable["tenantId"], query.user.tenantId));
37
+ }
38
+
39
+ if (query.payload.kind) {
40
+ conditions.push(eq(templateResourcesTable["kind"], query.payload.kind));
41
+ }
42
+ if (query.payload.locale) {
43
+ conditions.push(eq(templateResourcesTable["locale"], query.payload.locale));
44
+ }
45
+ if (query.payload.status) {
46
+ conditions.push(eq(templateResourcesTable["status"], query.payload.status));
47
+ }
48
+
49
+ const whereExpr = conditions.length > 0 ? and(...conditions) : undefined;
50
+
51
+ const rows = (await ctx.db
52
+ .select()
53
+ .from(templateResourcesTable)
54
+ .where(whereExpr)
55
+ .limit(500)) as TemplateResourceRow[];
56
+
57
+ return rows.map((row) => ({
58
+ id: String(row.id),
59
+ tenantId: row.tenantId,
60
+ slug: row.slug,
61
+ kind: row.kind,
62
+ locale: row.locale,
63
+ scope: row.scope,
64
+ status: row.status,
65
+ contentFormat: row.contentFormat,
66
+ updatedAt: row.updatedAt,
67
+ }));
68
+ },
69
+ });
@@ -0,0 +1,45 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { eq } from "drizzle-orm";
5
+ import { z } from "zod";
6
+ import type { TemplateResourceRow } from "../table";
7
+ import { templateResourcesTable } from "../table";
8
+ import { executor } from "./shared";
9
+
10
+ // Setzt einen Template-Eintrag auf status='active'. Typischer Workflow:
11
+ // User editiert ein Draft, ist zufrieden, drückt Publish.
12
+ //
13
+ // Tenant-Isolation: Template muss zum event.user.tenantId gehören
14
+ // (oder zu SYSTEM_TENANT wenn SystemAdmin). Cross-Tenant-Publish-
15
+ // Versuche → NotFound (Pattern aus row-level-security).
16
+ export const publishWrite = defineWriteHandler({
17
+ name: "publish",
18
+ schema: z.object({ id: z.string().min(1) }),
19
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
20
+ handler: async (event, ctx) => {
21
+ const existing = await fetchOne<TemplateResourceRow>(
22
+ ctx.db,
23
+ templateResourcesTable,
24
+ eq(templateResourcesTable["id"], event.payload.id),
25
+ );
26
+ // ctx.db ist via createTenantDb tenant-scoped — existing ist null wenn
27
+ // das Template einem fremden Tenant gehört (SystemAdmin-Cross-Tenant
28
+ // braucht tenantIdOverride im Schema, M2-Erweiterung).
29
+ if (!existing) {
30
+ return writeFailure(new NotFoundError("template-resource", event.payload.id));
31
+ }
32
+
33
+ const result = await executor.update(
34
+ {
35
+ id: existing.id,
36
+ version: existing.version,
37
+ changes: { status: "active" as const },
38
+ },
39
+ event.user,
40
+ ctx.db,
41
+ );
42
+ if (!result.isSuccess) return result;
43
+ return { isSuccess: true as const, data: { id: String(existing.id), status: "active" } };
44
+ },
45
+ });
@@ -0,0 +1,41 @@
1
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { z } from "zod";
3
+ import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_STATUSES } from "../constants";
4
+ import { templateResourceEntity, templateResourcesTable } from "../table";
5
+
6
+ // Single executor pro Bundle — Pattern aus text-content. Wird von allen
7
+ // 4 Handlers geteilt für create/update-Operationen mit Event-Store +
8
+ // Optimistic-Lock.
9
+ export const executor = createEventStoreExecutor(templateResourcesTable, templateResourceEntity, {
10
+ entityName: "template-resource",
11
+ });
12
+
13
+ // Slug-Regex symmetrisch zu text-content + plan-doc naming-convention.
14
+ export const slugSchema = z
15
+ .string()
16
+ .min(1)
17
+ .max(80)
18
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
19
+
20
+ export const localeSchema = z
21
+ .string()
22
+ .min(2)
23
+ .max(8)
24
+ .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "locale must be ISO 639-1 (e.g. de, en, en-us)");
25
+
26
+ export const kindSchema = z.enum(RENDER_KINDS);
27
+ export const contentFormatSchema = z.enum(CONTENT_FORMATS);
28
+ export const statusSchema = z.enum(TEMPLATE_STATUSES);
29
+
30
+ // Common Upsert-Payload — geteilt zwischen upsertSystem + upsertTenant.
31
+ // Unterschied: ACL + tenantId-Bestimmung, sonst identisch.
32
+ export const upsertPayloadSchema = z.object({
33
+ slug: slugSchema,
34
+ kind: kindSchema,
35
+ locale: localeSchema,
36
+ content: z.string().max(200_000),
37
+ contentFormat: contentFormatSchema,
38
+ variableSchema: z.record(z.string(), z.unknown()).default({}),
39
+ linkedResources: z.record(z.string(), z.string()).default({}),
40
+ parentTemplateId: z.string().min(1).optional(),
41
+ });
@@ -0,0 +1,75 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ defineWriteHandler,
4
+ SYSTEM_TENANT_ID,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { eq } from "drizzle-orm";
8
+ import type { TemplateResourceRow } from "../table";
9
+ import { templateResourcesTable } from "../table";
10
+ import { executor, upsertPayloadSchema } from "./shared";
11
+
12
+ // System-Template anlegen/updaten. Liegt unter SYSTEM_TENANT_ID,
13
+ // scope='system'. Nur SystemAdmin (globale Rolle). TenantAdmin kann
14
+ // keine System-Defaults überschreiben — der nutzt upsertTenant für
15
+ // Overrides.
16
+ export const upsertSystemWrite = defineWriteHandler({
17
+ name: "upsert-system",
18
+ schema: upsertPayloadSchema,
19
+ access: { roles: ["SystemAdmin"] },
20
+ handler: async (event, ctx) => {
21
+ const db = ctx.db;
22
+ const tenantId = SYSTEM_TENANT_ID as TenantId;
23
+ // executor-user muss SYSTEM_TENANT als tenantId haben, sonst sucht
24
+ // event-store stream unter user.tenantId statt SYSTEM_TENANT → conflict.
25
+ // Pattern symmetrisch zu text-content setWrite Override-Branch.
26
+ const executorUser = { ...event.user, tenantId };
27
+
28
+ const existing = await fetchOne<TemplateResourceRow>(
29
+ db,
30
+ templateResourcesTable,
31
+ eq(templateResourcesTable["tenantId"], tenantId),
32
+ eq(templateResourcesTable["slug"], event.payload.slug),
33
+ eq(templateResourcesTable["kind"], event.payload.kind),
34
+ eq(templateResourcesTable["locale"], event.payload.locale),
35
+ );
36
+
37
+ const fields = {
38
+ slug: event.payload.slug,
39
+ kind: event.payload.kind,
40
+ locale: event.payload.locale,
41
+ content: event.payload.content,
42
+ contentFormat: event.payload.contentFormat,
43
+ variableSchema: JSON.stringify(event.payload.variableSchema),
44
+ linkedResources: JSON.stringify(event.payload.linkedResources),
45
+ scope: "system" as const,
46
+ parentTemplateId: event.payload.parentTemplateId ?? null,
47
+ // System-Defaults sind sofort active (kein draft-Stage für seeds).
48
+ status: "active" as const,
49
+ };
50
+
51
+ if (existing) {
52
+ const result = await executor.update(
53
+ { id: existing.id, version: existing.version, changes: fields },
54
+ executorUser,
55
+ db,
56
+ );
57
+ if (!result.isSuccess) return result;
58
+ return {
59
+ isSuccess: true as const,
60
+ data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
61
+ };
62
+ }
63
+
64
+ const result = await executor.create({ ...fields, tenantId }, executorUser, db);
65
+ if (!result.isSuccess) return result;
66
+ return {
67
+ isSuccess: true as const,
68
+ data: {
69
+ id: String((result.data as { id: string | number }).id),
70
+ slug: event.payload.slug,
71
+ isNew: true,
72
+ },
73
+ };
74
+ },
75
+ });
@@ -0,0 +1,98 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ defineWriteHandler,
4
+ SYSTEM_TENANT_ID,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { AccessDeniedError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { eq } from "drizzle-orm";
9
+ import { z } from "zod";
10
+ import type { TemplateResourceRow } from "../table";
11
+ import { templateResourcesTable } from "../table";
12
+ import { executor, upsertPayloadSchema } from "./shared";
13
+
14
+ // Tenant-Override anlegen/updaten. Liegt unter event.user.tenantId,
15
+ // scope='tenant'. Default-Status='draft' — User publisht explizit
16
+ // via publish-Handler. SystemAdmin kann via tenantIdOverride für
17
+ // einen anderen Tenant schreiben (typisch: Plattform-Admin-UI das
18
+ // Tenant-Templates kuratiert).
19
+ export const upsertTenantWrite = defineWriteHandler({
20
+ name: "upsert-tenant",
21
+ schema: upsertPayloadSchema.extend({
22
+ tenantIdOverride: z.string().min(1).optional(),
23
+ status: z.enum(["draft", "active"]).default("draft"),
24
+ }),
25
+ access: { roles: ["TenantAdmin", "SystemAdmin"] },
26
+ handler: async (event, ctx) => {
27
+ const db = ctx.db;
28
+ const override = event.payload.tenantIdOverride;
29
+ if (override !== undefined && !event.user.roles.includes("SystemAdmin")) {
30
+ return writeFailure(
31
+ new AccessDeniedError({
32
+ i18nKey: "templateResolver.errors.tenantOverrideRequiresSystemAdmin",
33
+ details: { reason: "tenant_override_requires_system_admin" },
34
+ }),
35
+ );
36
+ }
37
+ // upsertTenant erzeugt scope='tenant'. SYSTEM_TENANT_ID-Override würde
38
+ // scope='tenant' unter SYSTEM_TENANT_ID schreiben → inkonsistenter Zustand
39
+ // (Resolver-Logik trennt sauber zwischen system+tenant). SystemAdmin muss
40
+ // upsertSystem für System-Defaults nutzen.
41
+ if (override === SYSTEM_TENANT_ID) {
42
+ return writeFailure(
43
+ new AccessDeniedError({
44
+ i18nKey: "templateResolver.errors.useUpsertSystemForSystemTenant",
45
+ details: { reason: "system_tenant_override_not_allowed_use_upsert_system" },
46
+ }),
47
+ );
48
+ }
49
+ const tenantId = (override ?? event.user.tenantId) as TenantId;
50
+ const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
51
+
52
+ const existing = await fetchOne<TemplateResourceRow>(
53
+ db,
54
+ templateResourcesTable,
55
+ eq(templateResourcesTable["tenantId"], tenantId),
56
+ eq(templateResourcesTable["slug"], event.payload.slug),
57
+ eq(templateResourcesTable["kind"], event.payload.kind),
58
+ eq(templateResourcesTable["locale"], event.payload.locale),
59
+ );
60
+
61
+ const fields = {
62
+ slug: event.payload.slug,
63
+ kind: event.payload.kind,
64
+ locale: event.payload.locale,
65
+ content: event.payload.content,
66
+ contentFormat: event.payload.contentFormat,
67
+ variableSchema: JSON.stringify(event.payload.variableSchema),
68
+ linkedResources: JSON.stringify(event.payload.linkedResources),
69
+ scope: "tenant" as const,
70
+ parentTemplateId: event.payload.parentTemplateId ?? null,
71
+ status: event.payload.status,
72
+ };
73
+
74
+ if (existing) {
75
+ const result = await executor.update(
76
+ { id: existing.id, version: existing.version, changes: fields },
77
+ executorUser,
78
+ db,
79
+ );
80
+ if (!result.isSuccess) return result;
81
+ return {
82
+ isSuccess: true as const,
83
+ data: { id: String(existing.id), slug: event.payload.slug, isNew: false },
84
+ };
85
+ }
86
+
87
+ const result = await executor.create({ ...fields, tenantId }, executorUser, db);
88
+ if (!result.isSuccess) return result;
89
+ return {
90
+ isSuccess: true as const,
91
+ data: {
92
+ id: String((result.data as { id: string | number }).id),
93
+ slug: event.payload.slug,
94
+ isNew: true,
95
+ },
96
+ };
97
+ },
98
+ });