@cosmicdrift/kumiko-bundled-features 0.174.0 → 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.
- package/package.json +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
- package/src/auth-email-password/seeding.ts +4 -2
- package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
- package/src/auth-mfa/token-secrets.ts +16 -14
- package/src/billing-foundation/subscription-tier-sync.ts +8 -1
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/config/handlers/reencrypt.job.ts +7 -18
- package/src/delivery/delivery-service.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
- package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
- package/src/document-ingest-foundation/events.ts +15 -0
- package/src/document-ingest-foundation/feature.ts +27 -2
- package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
- package/src/jobs/handlers/catalog.query.ts +1 -4
- package/src/jobs/handlers/trigger.write.ts +2 -1
- package/src/jobs/is-manual-trigger.ts +5 -0
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/managed-pages/screens/branding-screen.ts +1 -1
- package/src/presets/dsgvo-self-service.ts +10 -10
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
- package/src/shared/classify-stored-envelope.ts +21 -0
- package/src/shared/index.ts +4 -0
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
- package/src/tenant-settings/tenant-defaults.ts +3 -0
- package/src/tier-engine/compose-app.ts +2 -13
- package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
- package/src/user/seeding.ts +25 -1
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
- package/src/user-data-rights/feature.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
|
@@ -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
|
-
};
|