@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.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 (45) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
  3. package/src/legal-pages/README.md +26 -25
  4. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  5. package/src/legal-pages/feature.ts +40 -31
  6. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  7. package/src/legal-pages/web/client-plugin.ts +3 -3
  8. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  9. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  10. package/src/seo/robots-txt.ts +21 -1
  11. package/src/template-resolver/README.md +30 -1
  12. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  13. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  14. package/src/template-resolver/api.ts +11 -7
  15. package/src/template-resolver/constants.ts +22 -0
  16. package/src/template-resolver/feature.ts +16 -1
  17. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  18. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  19. package/src/template-resolver/handlers/set.write.ts +110 -0
  20. package/src/template-resolver/handlers/shared.ts +13 -2
  21. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  22. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  23. package/src/template-resolver/index.ts +7 -0
  24. package/src/template-resolver/qualified-names.ts +3 -0
  25. package/src/template-resolver/seeding.ts +126 -2
  26. package/src/template-resolver/table.ts +11 -2
  27. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  28. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  29. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  30. package/src/template-resolver/web/client-plugin.tsx +352 -0
  31. package/src/template-resolver/web/index.ts +8 -0
  32. package/src/text-content/README.md +0 -197
  33. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  34. package/src/text-content/api.ts +0 -86
  35. package/src/text-content/changes.json +0 -14
  36. package/src/text-content/constants.ts +0 -21
  37. package/src/text-content/feature.ts +0 -53
  38. package/src/text-content/handlers/by-slug.query.ts +0 -55
  39. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  40. package/src/text-content/handlers/set.write.ts +0 -141
  41. package/src/text-content/index.ts +0 -14
  42. package/src/text-content/seeding.ts +0 -139
  43. package/src/text-content/table.ts +0 -53
  44. package/src/text-content/web/client-plugin.tsx +0 -391
  45. package/src/text-content/web/index.ts +0 -8
@@ -1,86 +0,0 @@
1
- // TextContentApi — die Cross-Feature-Schnittstelle des text-content-
2
- // Features. Andere Features (z.B. legal-pages) importieren NUR den Type
3
- // hier und holen die Implementation runtime aus ctx.textContent.
4
- //
5
- // Pattern symmetrisch zu config: das Feature exportiert API-Type +
6
- // Factory, App-Bootstrap setzt die Instance via extraContext, consuming-
7
- // Features nutzen sie via require-Helper aus dem HandlerContext. So
8
- // bleiben Features durch Refactorings entkoppelt — wer textBlocksTable
9
- // umzieht oder die Query-Signatur ändert, muss nur die Factory anpassen.
10
-
11
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
12
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
- import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
14
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
15
- import { type TextBlockRow, textBlocksTable } from "./table";
16
-
17
- export type TextBlock = {
18
- readonly slug: string;
19
- readonly lang: string;
20
- readonly title: string;
21
- readonly body: string | null;
22
- readonly updatedAt: Date;
23
- };
24
-
25
- export type TextContentApi = {
26
- /**
27
- * Lookup eines TextBlocks by (tenantId, slug, lang). Null wenn nicht
28
- * existiert. Tenant-Scope wird vom Caller mitgegeben — kein implicit
29
- * tenantId aus Session, weil die API auch von Boot-Jobs ohne
30
- * Session-User aufgerufen wird (siehe legal-pages bootCheck).
31
- */
32
- readonly getBlock: (args: {
33
- tenantId: TenantId;
34
- slug: string;
35
- lang: string;
36
- }) => Promise<TextBlock | null>;
37
- };
38
-
39
- // @wrapper-known semantic-alias
40
- export function createTextContentApi(db: DbConnection): TextContentApi {
41
- return {
42
- getBlock: async ({ tenantId, slug, lang }) => {
43
- const row = await fetchOne<TextBlockRow>(db, textBlocksTable, { tenantId, slug, lang });
44
- if (!row) return null;
45
- return {
46
- slug: row.slug,
47
- lang: row.lang,
48
- title: row.title,
49
- body: row.body,
50
- updatedAt: row.updatedAt,
51
- };
52
- },
53
- };
54
- }
55
-
56
- // Single point of truth für "dieser Handler braucht text-content".
57
- // Wirft InternalError mit Wiring-Hinweis statt bare Error — so liest
58
- // die Debug-Session die exakte Boot-Lücke ("text-content feature not
59
- // wired into AppContext") statt eines generischen undefined-bugs.
60
- //
61
- // Pattern symmetrisch zu requireConfigResolver/requireConfigEncryption.
62
- // Akzeptiert HandlerContext + AppContext (Job-Context) — beide haben
63
- // SharedContextFields als Basis. Das narrowing geschieht via shape-check
64
- // auf das optionale `textContent`-Feld (kein Type-Lookup ins framework).
65
- export function requireTextContent(
66
- ctx: { readonly textContent?: TextContentApi } | object,
67
- callerName: string,
68
- ): TextContentApi {
69
- // @cast-boundary engine-bridge ctx ist Framework-Container (HandlerContext
70
- // | AppContext), textContent kommt per extraContext aus dem App-Bootstrap.
71
- const api = (ctx as { textContent?: TextContentApi }).textContent;
72
- if (!api) {
73
- throw new InternalError({
74
- message:
75
- `[${callerName}] ctx.textContent missing — App-Bootstrap muss ` +
76
- `extraContext: { textContent: createTextContentApi(db) } setzen ` +
77
- `(siehe text-content/README.md).`,
78
- });
79
- }
80
- return api;
81
- }
82
-
83
- // Re-export für Test-Helper die selbst eine Session-User-scoped Variante
84
- // brauchen — der Standard-Use-Case (Routes/Boot-Jobs) gibt tenantId
85
- // explizit mit, deshalb ist getBlock session-agnostisch.
86
- export type { SessionUser };
@@ -1,14 +0,0 @@
1
- [
2
- {
3
- "version": "0.165.0",
4
- "type": "fix",
5
- "title": "Legal pages seed: SYSTEM_USER instead of tenant user",
6
- "detail": "Seed now runs as SYSTEM_USER and skips identical re-seeds — prevents unnecessary DB writes."
7
- },
8
- {
9
- "version": "0.130.2",
10
- "type": "fix",
11
- "title": "Content-Tree + Config-Nav Sysadmin-Shell polish: - text-content: Leaf-Knoten tragen jetzt…",
12
- "detail": "Content-Tree + Config-Nav Sysadmin-Shell polish: - text-content: Leaf-Knoten tragen jetzt ein `file`-Icon statt eines Dots; der Editor läuft auf der Page-Shell (`Form`-Primitive mit Card statt des entfernten `FormPanelShell`). - Sidebar-Nav bekommt ein Suchfeld, das den Baum live filtert (Treffer + ihre Ancestors bleiben, zugeklappte Ordner öffnen für die Suche). - Ordner-Knoten zeigen `folder-open` wenn ausgeklappt. - NAV_ICONS um `server`, `mail`, `lock`, `hash`, `download`, `folder-open` ergänzt — SMTP-/Config-Nav-Kinder (z.B. „Email-Versand\") rendern damit ein Icon statt blank. - Verschachtelte Provider-Ordner (Content-Tree) rendern ihre Kinder in einem `<ul>` (valides HTML + Einrück-Stufe pro Tiefe) statt `<li>`-in-`<li>`. - Platform-Overview: `user:query:user:list` in der Allowlist (behebt den Overview-Crash). - @cosmicdrift/kumiko-renderer-web@0.130.2 - @cosmicdrift/kumiko-renderer@0.130.2 - @cosmicdrift/kumiko-framework@0.130.2 - @cosmicdrift/kumiko-dispatcher-live@0.130.2 - @cosmicdrift/kumiko-headless@0.130.2"
13
- }
14
- ]
@@ -1,21 +0,0 @@
1
- // @runtime client
2
- // Feature name
3
- export const TEXT_CONTENT_FEATURE = "text-content" as const;
4
-
5
- // Qualified write handler names (QN format: scope:type:name)
6
- export const TextContentHandlers = {
7
- set: "text-content:write:set",
8
- } as const;
9
-
10
- // Qualified query handler names (QN format: scope:type:name)
11
- export const TextContentQueries = {
12
- bySlug: "text-content:query:by-slug",
13
- byTenant: "text-content:query:by-tenant",
14
- } as const;
15
-
16
- // Error codes
17
- export const TextContentErrors = {
18
- notFound: "text_block_not_found",
19
- invalidSlug: "invalid_slug",
20
- invalidLang: "invalid_lang",
21
- } as const;
@@ -1,53 +0,0 @@
1
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
- import { bySlugQuery } from "./handlers/by-slug.query";
3
- import { byTenantQuery } from "./handlers/by-tenant.query";
4
- import { setWrite } from "./handlers/set.write";
5
- import { textBlockEntity } from "./table";
6
-
7
- // text-content — generischer Container für statische Texte (Impressum,
8
- // Datenschutz, FAQ, About, ToS, Marketing-Snippets). Pro
9
- // (tenantId, slug, lang) genau ein Block. Inhalt ist Markdown — die
10
- // Konvertierung zu HTML übernehmen Consumer-Features wie legal-pages
11
- // (das opt-in obendrauf-Feature für Compliance-Pages).
12
- //
13
- // Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
14
- // das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
15
- // query + by-tenant-list-query — Routes/Render kommen pro Use-Case
16
- // (legal-pages, Visual-Tree, etc.).
17
- //
18
- // **Visual-Tree-Integration (V.1.2)**: r.treeActions deklariert die
19
- // Edit-Actions für Cross-Feature-Linking via buildTarget. Der Handle
20
- // wird via setup-export propagiert (Memory `[EventDef-Exports-Pattern]`),
21
- // sodass andere Features compile-time-typed Cross-Feature-Edits triggern
22
- // können — siehe legal-pages's TreeProvider der text-content:edit als
23
- // Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
24
- export function createTextContentFeature() {
25
- return defineFeature("text-content", (r) => {
26
- r.describe(
27
- "Generic Markdown text store keyed by `(tenantId, slug, lang)` \u2014 one row per combination in the `read_text_blocks` entity table. Provides `text-content:write:set` (TenantAdmin upsert) and `text-content:query:by-slug` (anonymous-capable read); use `SYSTEM_TENANT_ID` as the tenant for app-wide texts such as imprint, privacy policy, or FAQ. Other features (e.g. `legal-pages`) read blocks without a direct code import via the `createTextContentApi` / `requireTextContent` extraContext pattern.",
28
- );
29
- r.uiHints({
30
- displayLabel: "Text Content \u00b7 Markdown Store",
31
- category: "content",
32
- recommended: false,
33
- });
34
- r.entity("text-block", textBlockEntity);
35
-
36
- const handlers = {
37
- set: r.writeHandler(setWrite),
38
- };
39
-
40
- const queries = {
41
- bySlug: r.queryHandler(bySlugQuery),
42
- byTenant: r.queryHandler(byTenantQuery),
43
- };
44
-
45
- const treeHandle = r.treeActions({
46
- edit: { args: { slug: "" as string, lang: "" as string } },
47
- list: {},
48
- create: { args: { folder: "" as string } },
49
- });
50
-
51
- return { handlers, queries, treeHandle };
52
- });
53
- }
@@ -1,55 +0,0 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import {
3
- crossTenantOverrideDenied,
4
- defineQueryHandler,
5
- } from "@cosmicdrift/kumiko-framework/engine";
6
- import { z } from "zod";
7
- import { type TextBlockRow, textBlocksTable } from "../table";
8
-
9
- // Public-Read by (tenantId, slug, lang). Anonymous: muss `anonymous`
10
- // in roles enthalten — openToAll alleine ist auth-only (Regression-
11
- // Guard). Tenant-Scope kommt default aus query.user.tenantId (anonymous-
12
- // context setzt SYSTEM_TENANT_ID oder Host-resolved-tenant je nach App-
13
- // Setup). Optional `tenantIdOverride` (SystemAdmin-only) erlaubt cross-
14
- // tenant Read — symmetrisch zum set-handler. Use-case: Edit-UI lädt den
15
- // SYSTEM_TENANT-Block für SystemAdmin der nicht direkt drauf member ist.
16
- export const bySlugQuery = defineQueryHandler({
17
- name: "by-slug",
18
- schema: z.object({
19
- slug: z.string().min(1).max(64),
20
- lang: z.string().min(2).max(8),
21
- /** Optional cross-tenant read — nur für SystemAdmin. Siehe
22
- * set.write.ts für die symmetrische write-side. */
23
- tenantIdOverride: z.string().min(1).optional(),
24
- }),
25
- // Public-Read: muss explizit `anonymous` enthalten damit no-JWT-
26
- // Visitors auf Marketing-/Legal-Pages den Text sehen. openToAll
27
- // alleine ist auth-only (Regression-Guard) — siehe
28
- // docs/plans/datenschutz/legal-artifacts.md.
29
- access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
30
- handler: async (query, ctx) => {
31
- const override = query.payload.tenantIdOverride;
32
- const overrideDenied = crossTenantOverrideDenied(
33
- query.user,
34
- override,
35
- "textContent.errors.tenantOverrideRequiresSystemAdmin",
36
- );
37
- if (overrideDenied) throw overrideDenied;
38
- const tenantId = override ?? query.user.tenantId;
39
- const row = await fetchOne<TextBlockRow>(ctx.db, textBlocksTable, {
40
- tenantId,
41
- slug: query.payload.slug,
42
- lang: query.payload.lang,
43
- });
44
-
45
- if (!row) return null;
46
- return {
47
- slug: row.slug,
48
- lang: row.lang,
49
- title: row.title,
50
- body: row.body,
51
- folder: row.folder,
52
- updatedAt: row.updatedAt,
53
- };
54
- },
55
- });
@@ -1,60 +0,0 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- crossTenantOverrideDenied,
5
- defineQueryHandler,
6
- } from "@cosmicdrift/kumiko-framework/engine";
7
- import { z } from "zod";
8
- import { type TextBlockRow, textBlocksTable } from "../table";
9
-
10
- // Public-Read aller Text-Blocks für einen Tenant. Use-Case: Visual-Tree-
11
- // Provider lädt die Slug-Liste zur Sidebar-Render. Anonymous: explizit
12
- // in roles damit no-JWT-Visitors auch lesen können (Marketing-Sidebar
13
- // auf Public-Pages). Tenant-Scope kommt aus query.user.tenantId; optional
14
- // `tenantIdOverride` (SystemAdmin-only) — symmetrisch zu by-slug.query.
15
- //
16
- // **Listing statt single-row**: anders als by-slug returnt das hier
17
- // `{ blocks: [...] }` mit allen Slugs des Tenants. Pro Slug nur die
18
- // Summary-Felder (kein full body — den lädt der Editor on-demand via
19
- // by-slug). Hält die Sidebar-Payload klein bei vielen Slugs.
20
- export type TextBlockSummary = {
21
- readonly slug: string;
22
- readonly lang: string;
23
- readonly title: string;
24
- readonly body: string | null;
25
- readonly folder: string | null;
26
- readonly updatedAt: Date;
27
- };
28
-
29
- export const byTenantQuery = defineQueryHandler({
30
- name: "by-tenant",
31
- schema: z.object({
32
- /** Optional cross-tenant read — nur für SystemAdmin. Symmetrisch
33
- * zur by-slug.query und set.write Override-Logik. */
34
- tenantIdOverride: z.string().min(1).optional(),
35
- }),
36
- access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
37
- handler: async (query, ctx) => {
38
- const override = query.payload.tenantIdOverride;
39
- const overrideDenied = crossTenantOverrideDenied(
40
- query.user,
41
- override,
42
- "textContent.errors.tenantOverrideRequiresSystemAdmin",
43
- );
44
- if (overrideDenied) throw overrideDenied;
45
- const tenantId = override ?? query.user.tenantId;
46
- const rows = castTenantRows<TextBlockRow>(
47
- await selectMany(ctx.db, textBlocksTable, { tenantId: tenantId }),
48
- );
49
- return {
50
- blocks: rows.map((row) => ({
51
- slug: row.slug,
52
- lang: row.lang,
53
- title: row.title,
54
- body: row.body,
55
- folder: row.folder,
56
- updatedAt: row.updatedAt,
57
- })),
58
- };
59
- },
60
- });
@@ -1,141 +0,0 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
- import {
4
- crossTenantOverrideDenied,
5
- defineWriteHandler,
6
- type TenantId,
7
- } from "@cosmicdrift/kumiko-framework/engine";
8
- import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
- import { z } from "zod";
10
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
11
-
12
- const slugSchema = z
13
- .string()
14
- .min(1)
15
- .max(64)
16
- .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
17
-
18
- // Folder-Convention V.1.4: gleiches kebab-Pattern wie slug, optional
19
- // `/`-Separator für nested folders ("legal/imprint", "page/marketing").
20
- // Multi-level wird vom Visual-Tree-Grouping flat-rendered bis V.1.5
21
- // rekursive Hierarchie braucht — dann erweitert sich nur der UI-Render,
22
- // nicht das Schema.
23
- const folderSchema = z
24
- .string()
25
- .min(1)
26
- .max(128)
27
- .regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
28
-
29
- const langSchema = z
30
- .string()
31
- .min(2)
32
- .max(8)
33
- .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "lang must be ISO 639-1 (e.g. de, en, en-us)");
34
-
35
- const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
36
- entityName: "text-block",
37
- });
38
-
39
- // Upsert handler — eine Operation pro (tenantId, slug, lang). Bei
40
- // existierender Row → update, sonst → create. Tenant-Scope kommt
41
- // default aus event.user. Tenant-Admins setzen Texte für ihren
42
- // eigenen Tenant; Plattform-Sysadmins können via optional
43
- // `tenantIdOverride` für einen anderen Tenant schreiben (typisch:
44
- // SYSTEM_TENANT_ID für legal-pages-content den die ganze Plattform
45
- // teilt). Override ist SystemAdmin-only — TenantAdmin's Override-
46
- // Versuch → 403.
47
- export const setWrite = defineWriteHandler({
48
- name: "set",
49
- schema: z.object({
50
- slug: slugSchema,
51
- lang: langSchema,
52
- title: z.string().min(1).max(200),
53
- body: z.string().max(100_000).nullable(),
54
- /** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null
55
- * → root-node (kein Folder). Tree groupt nach diesem Field, slug
56
- * bleibt flach + kebab-validiert. Beispiele: "page", "legal",
57
- * "page/marketing". */
58
- folder: folderSchema.nullable().optional(),
59
- /** Optional cross-tenant write — nur für SystemAdmin. Typischer
60
- * use-case: legal-pages-Edit-UI lässt SystemAdmin auf
61
- * SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
62
- * eigenen platform-tenant und legal-pages-routes lesen ihn
63
- * nicht). TenantAdmin's Versuch → ForbiddenError. */
64
- tenantIdOverride: z.string().min(1).optional(),
65
- }),
66
- // SystemAdmin ist eine GLOBALE Rolle (users.roles), TenantAdmin pro
67
- // tenant-membership. SystemAdmin braucht beide Pfade explizit weil
68
- // er nicht implicit TenantAdmin auf jedem Tenant ist (siehe
69
- // project_global_roles_sysadmin memory). Ohne SystemAdmin könnte
70
- // niemand SYSTEM_TENANT-Texte setzen — nur via Test-Helper.
71
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
72
- handler: async (event, ctx) => {
73
- const db = ctx.db;
74
- const override = event.payload.tenantIdOverride;
75
- const overrideDenied = crossTenantOverrideDenied(
76
- event.user,
77
- override,
78
- "textContent.errors.tenantOverrideRequiresSystemAdmin",
79
- );
80
- if (overrideDenied) return writeFailure(overrideDenied);
81
- const tenantId = override ?? event.user.tenantId;
82
- // Bei tenantIdOverride muss auch der user-context auf den ziel-tenant
83
- // umgestellt werden, sonst läuft der event-store-Lookup
84
- // (getStreamVersion) gegen user.tenantId statt tenantId — und findet
85
- // den stream nicht → version_conflict obwohl die projection-row da ist.
86
- // Symmetric with seedTextBlock, which defaults by to
87
- // createSystemUser(tenantId) so stream lookup and projection tenant match.
88
- const executorUser =
89
- override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
90
-
91
- const existing = await fetchOne<TextBlockRow>(db, textBlocksTable, {
92
- tenantId,
93
- slug: event.payload.slug,
94
- lang: event.payload.lang,
95
- });
96
-
97
- // V.1.4 folder: optional + null erlaubt (root-node). Optional-Chain
98
- // mapped undefined → null damit drizzle nullable-column konsistent
99
- // schreibt (sonst SQL-default kicked-in vs. explicit-null Unterschied).
100
- const folder = event.payload.folder ?? null;
101
-
102
- if (existing) {
103
- const result = await executor.update(
104
- {
105
- id: existing.id,
106
- version: existing.version,
107
- changes: {
108
- title: event.payload.title,
109
- body: event.payload.body,
110
- folder,
111
- },
112
- },
113
- executorUser,
114
- db,
115
- );
116
- if (!result.isSuccess) return result;
117
- return {
118
- isSuccess: true as const,
119
- data: { slug: event.payload.slug, lang: event.payload.lang, isNew: false },
120
- };
121
- }
122
-
123
- const result = await executor.create(
124
- {
125
- slug: event.payload.slug,
126
- lang: event.payload.lang,
127
- title: event.payload.title,
128
- body: event.payload.body,
129
- folder,
130
- tenantId,
131
- },
132
- executorUser,
133
- db,
134
- );
135
- if (!result.isSuccess) return result;
136
- return {
137
- isSuccess: true as const,
138
- data: { slug: event.payload.slug, lang: event.payload.lang, isNew: true },
139
- };
140
- },
141
- });
@@ -1,14 +0,0 @@
1
- export {
2
- createTextContentApi,
3
- requireTextContent,
4
- type TextBlock,
5
- type TextContentApi,
6
- } from "./api";
7
- export {
8
- TEXT_CONTENT_FEATURE,
9
- TextContentErrors,
10
- TextContentHandlers,
11
- TextContentQueries,
12
- } from "./constants";
13
- export { createTextContentFeature } from "./feature";
14
- export { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
@@ -1,139 +0,0 @@
1
- // Boot-/test-seed helpers for text-content. Creates a TextBlock via the
2
- // event-store executor — same path as the real set-handler, without the
3
- // access check. Default ifExists="skip": only missing blocks; opt-in
4
- // update for demo fixtures / legal template authority.
5
-
6
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
7
- import {
8
- createEventStoreExecutor,
9
- createTenantDb,
10
- type DbConnection,
11
- } from "@cosmicdrift/kumiko-framework/db";
12
- import {
13
- createSystemUser,
14
- type SessionUser,
15
- SYSTEM_TENANT_ID,
16
- type TenantId,
17
- } from "@cosmicdrift/kumiko-framework/engine";
18
- import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
19
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
20
-
21
- const executor = createEventStoreExecutor(textBlocksTable, textBlockEntity, {
22
- entityName: "text-block",
23
- });
24
-
25
- export type SeedTextBlockOptions = {
26
- readonly tenantId: TenantId;
27
- readonly slug: string;
28
- readonly lang: string;
29
- readonly title: string;
30
- readonly body?: string | null;
31
- /** V.1.4: Folder path for visual-tree grouping. Optional + null =
32
- * root node. Seed path bypasses slugSchema/folderSchema validation
33
- * (system-trusted), but app builders should keep kebab-only so
34
- * set.write can overwrite the seeded row later. */
35
- readonly folder?: string | null;
36
- readonly by?: SessionUser;
37
- readonly ifExists?: SeedIfExists;
38
- };
39
-
40
- export async function seedTextBlock(
41
- db: DbConnection,
42
- opts: SeedTextBlockOptions,
43
- ): Promise<{ id: string }> {
44
- // Default actor is createSystemUser(tenantId) — same as config-seed —
45
- // so prod boot seeds (legal pages) do not pull TestUsers / stack into
46
- // the app bundle or stamp phantom test fixture IDs into the audit trail.
47
- const by = opts.by ?? createSystemUser(opts.tenantId);
48
- // executor.create expects TenantDb — wrap so runtime tenant-scope checks apply.
49
- const tdb = createTenantDb(db, opts.tenantId, "system");
50
-
51
- const existing = await fetchOne<TextBlockRow>(db, textBlocksTable, {
52
- tenantId: opts.tenantId,
53
- slug: opts.slug,
54
- lang: opts.lang,
55
- });
56
-
57
- const folder = opts.folder ?? null;
58
- const body = opts.body ?? null;
59
-
60
- return runEventStoreSeed({
61
- existing,
62
- ifExists: opts.ifExists,
63
- create: async () => {
64
- const result = await executor.create(
65
- {
66
- slug: opts.slug,
67
- lang: opts.lang,
68
- title: opts.title,
69
- body,
70
- folder,
71
- tenantId: opts.tenantId,
72
- },
73
- by,
74
- tdb,
75
- );
76
- if (!result.isSuccess) {
77
- throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
78
- }
79
- // @cast-boundary db-row: executor.create result.data is the
80
- // inserted Drizzle row (Record<string, unknown>), projected
81
- // after INSERT/RETURNING onto TextBlockRow. Runtime check below.
82
- const data = result.data as Partial<TextBlockRow>;
83
- if (data.id === undefined) {
84
- throw new Error("seedTextBlock: executor.create did not return an id");
85
- }
86
- return { id: data.id };
87
- },
88
- update: async (row) => {
89
- // Skip no-op updates so legal re-seed on every pod boot does not
90
- // bump version/updatedAt (ETag / "last changed" / event-store growth).
91
- if (row.title === opts.title && row.body === body && row.folder === folder) {
92
- return { id: row.id };
93
- }
94
- const result = await executor.update(
95
- {
96
- id: row.id,
97
- version: row.version,
98
- changes: { title: opts.title, body, folder },
99
- },
100
- by,
101
- tdb,
102
- );
103
- if (!result.isSuccess) {
104
- throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
105
- }
106
- return { id: row.id };
107
- },
108
- });
109
- }
110
-
111
- export type LegalContentBlock = {
112
- readonly slug: string;
113
- readonly lang: string;
114
- readonly title: string;
115
- readonly body: string;
116
- };
117
-
118
- // Boot-seed for legal/marketing copy from a JSON template. `ifExists:
119
- // "update"` is load-bearing: the template is authoritative — otherwise
120
- // legally required additions never reach already-seeded prod rows.
121
- export async function seedLegalContentFromJson(
122
- db: DbConnection,
123
- blocks: readonly LegalContentBlock[],
124
- opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
125
- ): Promise<void> {
126
- const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
127
- const by = opts.by ?? createSystemUser(tenantId);
128
- for (const block of blocks) {
129
- await seedTextBlock(db, {
130
- tenantId,
131
- slug: block.slug,
132
- lang: block.lang,
133
- title: block.title,
134
- body: block.body,
135
- ifExists: "update",
136
- by,
137
- });
138
- }
139
- }
@@ -1,53 +0,0 @@
1
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
- import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
3
-
4
- // TextBlock — generischer Container für statische Texte (legal pages,
5
- // FAQ, About, ToS, Marketing-Snippets). Pro (tenantId, slug, lang) genau
6
- // eine Row. SYSTEM_TENANT_ID für app-weite Texte (Impressum etc.), sonst
7
- // Tenant-eigene Texte.
8
- //
9
- // Inhaltsformat ist Markdown (App-Renderer entscheidet Markdown→HTML).
10
- // Body bleibt nullable damit ein leerer Block existieren kann (z.B.
11
- // während Tenant-Onboarding bevor der Admin den finalen Text schreibt).
12
- export const textBlockEntity = createEntity({
13
- table: "read_text_blocks",
14
- fields: {
15
- slug: createTextField({ required: true }),
16
- lang: createTextField({ required: true }),
17
- title: createTextField({ required: true }),
18
- // Body is CMS-content authored by TenantAdmin/SystemAdmin (legal pages,
19
- // FAQ, ToS, marketing) — business data, not user-generated content.
20
- body: createTextField({ allowPlaintext: "is-business-data" }),
21
- // V.1.4: explicit folder-Hierarchie statt `:`-Encoding im Slug.
22
- // Visual-Tree gruppiert nach diesem Field; null/undefined → root.
23
- // Pflicht-Constraint im set.write-Schema (kebab-only wie slug,
24
- // damit App-Builder konsistente Naming-Conventions haben).
25
- folder: createTextField({}),
26
- },
27
- indexes: [
28
- { unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
29
- ],
30
- });
31
-
32
- export const textBlocksTable = buildEntityTable("text-block", textBlockEntity);
33
-
34
- // Concrete Row-Type — single-source dafür dass die unknown-Werte die
35
- // Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
36
- // werden (statt 6× `row["x"] as Y` Casts in Handlern + Seeding).
37
- // Kommt aus `entity.fields` + Standard-Spalten (id, version, tenantId,
38
- // createdAt, updatedAt, createdBy, updatedBy) die buildBaseColumns
39
- // erzwingt.
40
- export type TextBlockRow = {
41
- readonly id: string;
42
- readonly version: number;
43
- readonly tenantId: string;
44
- readonly slug: string;
45
- readonly lang: string;
46
- readonly title: string;
47
- readonly body: string | null;
48
- readonly folder: string | null;
49
- readonly createdAt: Date;
50
- readonly updatedAt: Date;
51
- readonly createdBy: string;
52
- readonly updatedBy: string;
53
- };