@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,6 +1,9 @@
|
|
|
1
1
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { bySlugQuery } from "./handlers/by-slug.query";
|
|
3
|
+
import { byTenantQuery } from "./handlers/by-tenant.query";
|
|
2
4
|
import { findByIdQuery } from "./handlers/find-by-id.query";
|
|
3
5
|
import { listQuery } from "./handlers/list.query";
|
|
6
|
+
import { setWrite } from "./handlers/set.write";
|
|
4
7
|
import { archiveWrite, publishWrite } from "./handlers/toggle-status.write";
|
|
5
8
|
import { upsertSystemWrite } from "./handlers/upsert-system.write";
|
|
6
9
|
import { upsertTenantWrite } from "./handlers/upsert-tenant.write";
|
|
@@ -31,13 +34,25 @@ export function createTemplateResolverFeature() {
|
|
|
31
34
|
upsertTenant: r.writeHandler(upsertTenantWrite),
|
|
32
35
|
publish: r.writeHandler(publishWrite),
|
|
33
36
|
archive: r.writeHandler(archiveWrite),
|
|
37
|
+
set: r.writeHandler(setWrite),
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
const queries = {
|
|
37
41
|
findById: r.queryHandler(findByIdQuery),
|
|
38
42
|
list: r.queryHandler(listQuery),
|
|
43
|
+
bySlug: r.queryHandler(bySlugQuery),
|
|
44
|
+
byTenant: r.queryHandler(byTenantQuery),
|
|
39
45
|
};
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
// Visual-tree actions for the text-block content tree. The handle is
|
|
48
|
+
// propagated through the setup export so other features can build
|
|
49
|
+
// compile-time-typed cross-feature edit targets.
|
|
50
|
+
const treeHandle = r.treeActions({
|
|
51
|
+
edit: { args: { slug: "" as string, locale: "" as string } },
|
|
52
|
+
list: {},
|
|
53
|
+
create: { args: { folder: "" as string } },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return { handlers, queries, treeHandle };
|
|
42
57
|
});
|
|
43
58
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { TEXT_BLOCK_KIND } from "../constants";
|
|
8
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table";
|
|
9
|
+
|
|
10
|
+
// Public read of a single text-block by (tenantId, slug, locale). Anonymous
|
|
11
|
+
// must be listed explicitly — `openToAll` alone is auth-only (regression
|
|
12
|
+
// guard). The kind is pinned to `text-block`: mail templates and AI prompts
|
|
13
|
+
// live in the same table and must not be readable without a session.
|
|
14
|
+
//
|
|
15
|
+
// Tenant scope defaults to query.user.tenantId (an anonymous context resolves
|
|
16
|
+
// to SYSTEM_TENANT_ID or the host-resolved tenant, depending on app setup).
|
|
17
|
+
// Optional `tenantIdOverride` (SystemAdmin-only) allows a cross-tenant read —
|
|
18
|
+
// symmetric to set.write.
|
|
19
|
+
export const bySlugQuery = defineQueryHandler({
|
|
20
|
+
name: "by-slug",
|
|
21
|
+
schema: z.object({
|
|
22
|
+
slug: z.string().min(1).max(80),
|
|
23
|
+
locale: z.string().min(2).max(8),
|
|
24
|
+
/** Optional cross-tenant read — SystemAdmin only. See set.write.ts. */
|
|
25
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
26
|
+
}),
|
|
27
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
28
|
+
handler: async (query, ctx) => {
|
|
29
|
+
const override = query.payload.tenantIdOverride;
|
|
30
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
31
|
+
query.user,
|
|
32
|
+
override,
|
|
33
|
+
"templateResolver.errors.tenantOverrideRequiresSystemAdmin",
|
|
34
|
+
);
|
|
35
|
+
if (overrideDenied) throw overrideDenied;
|
|
36
|
+
const tenantId = override ?? query.user.tenantId;
|
|
37
|
+
const row = await fetchOne<TemplateResourceRow>(ctx.db, templateResourcesTable, {
|
|
38
|
+
tenantId,
|
|
39
|
+
slug: query.payload.slug,
|
|
40
|
+
kind: TEXT_BLOCK_KIND,
|
|
41
|
+
locale: query.payload.locale,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!row) return null;
|
|
45
|
+
return {
|
|
46
|
+
slug: row.slug,
|
|
47
|
+
locale: row.locale,
|
|
48
|
+
title: row.title,
|
|
49
|
+
content: row.content,
|
|
50
|
+
folder: row.folder,
|
|
51
|
+
updatedAt: row.updatedAt,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { TEXT_BLOCK_KIND } from "../constants";
|
|
9
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table";
|
|
10
|
+
|
|
11
|
+
// All text-blocks of one tenant — feeds the content tree sidebar. Anonymous
|
|
12
|
+
// is listed explicitly so no-JWT visitors get the sidebar on public pages;
|
|
13
|
+
// `kind` is pinned to text-block for the same reason as in by-slug.
|
|
14
|
+
//
|
|
15
|
+
// Unlike by-slug this returns summaries for every slug. The body travels along
|
|
16
|
+
// because the tree marks empty blocks as stubs; full render content for a
|
|
17
|
+
// single block still comes from by-slug.
|
|
18
|
+
export type TextBlockSummary = {
|
|
19
|
+
readonly slug: string;
|
|
20
|
+
readonly locale: string;
|
|
21
|
+
readonly title: string | null;
|
|
22
|
+
readonly content: string | null;
|
|
23
|
+
readonly folder: string | null;
|
|
24
|
+
readonly updatedAt: Date;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const byTenantQuery = defineQueryHandler({
|
|
28
|
+
name: "by-tenant",
|
|
29
|
+
schema: z.object({
|
|
30
|
+
/** Optional cross-tenant read — SystemAdmin only. Symmetric to by-slug. */
|
|
31
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
32
|
+
}),
|
|
33
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
34
|
+
handler: async (query, ctx) => {
|
|
35
|
+
const override = query.payload.tenantIdOverride;
|
|
36
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
37
|
+
query.user,
|
|
38
|
+
override,
|
|
39
|
+
"templateResolver.errors.tenantOverrideRequiresSystemAdmin",
|
|
40
|
+
);
|
|
41
|
+
if (overrideDenied) throw overrideDenied;
|
|
42
|
+
const tenantId = override ?? query.user.tenantId;
|
|
43
|
+
const rows = castTenantRows<TemplateResourceRow>(
|
|
44
|
+
await selectMany(ctx.db, templateResourcesTable, { tenantId, kind: TEXT_BLOCK_KIND }),
|
|
45
|
+
);
|
|
46
|
+
return {
|
|
47
|
+
blocks: rows.map((row) => ({
|
|
48
|
+
slug: row.slug,
|
|
49
|
+
locale: row.locale,
|
|
50
|
+
title: row.title,
|
|
51
|
+
content: row.content,
|
|
52
|
+
folder: row.folder,
|
|
53
|
+
updatedAt: row.updatedAt,
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
crossTenantOverrideDenied,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
SYSTEM_TENANT_ID,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { TEXT_BLOCK_KIND } from "../constants";
|
|
11
|
+
import { type TemplateResourceRow, templateResourcesTable } from "../table";
|
|
12
|
+
import { contentFormatSchema, executor, folderSchema, localeSchema, slugSchema } from "./shared";
|
|
13
|
+
|
|
14
|
+
// Upsert of a single text-block, one operation per (tenantId, slug, locale).
|
|
15
|
+
// This is the authoring path for kind=text-block: unlike upsertTenant it takes
|
|
16
|
+
// a title and a folder, has no draft stage (text blocks are live when saved)
|
|
17
|
+
// and it may write onto SYSTEM_TENANT_ID, which is where legal and marketing
|
|
18
|
+
// copy lives. Cross-tenant writes are SystemAdmin-only.
|
|
19
|
+
export const setWrite = defineWriteHandler({
|
|
20
|
+
name: "set",
|
|
21
|
+
schema: z.object({
|
|
22
|
+
slug: slugSchema,
|
|
23
|
+
locale: localeSchema,
|
|
24
|
+
title: z.string().min(1).max(200),
|
|
25
|
+
content: z.string().max(200_000).nullable(),
|
|
26
|
+
contentFormat: contentFormatSchema.default("markdown"),
|
|
27
|
+
/** Folder path for the content tree. Optional + null = root node. */
|
|
28
|
+
folder: folderSchema.nullable().optional(),
|
|
29
|
+
/** Optional cross-tenant write — SystemAdmin only. Typical use: the
|
|
30
|
+
* editor lets a SystemAdmin write onto SYSTEM_TENANT_ID, otherwise the
|
|
31
|
+
* text lands on their own platform tenant and the legal routes miss it. */
|
|
32
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
33
|
+
}),
|
|
34
|
+
// SystemAdmin is a global role, TenantAdmin is per tenant-membership — both
|
|
35
|
+
// paths are needed explicitly, otherwise nobody can set SYSTEM_TENANT texts.
|
|
36
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
37
|
+
handler: async (event, ctx) => {
|
|
38
|
+
const db = ctx.db;
|
|
39
|
+
const override = event.payload.tenantIdOverride;
|
|
40
|
+
const overrideDenied = crossTenantOverrideDenied(
|
|
41
|
+
event.user,
|
|
42
|
+
override,
|
|
43
|
+
"templateResolver.errors.tenantOverrideRequiresSystemAdmin",
|
|
44
|
+
);
|
|
45
|
+
if (overrideDenied) return writeFailure(overrideDenied);
|
|
46
|
+
// @cast-boundary engine-payload — override is a zod-validated string, the
|
|
47
|
+
// user's own tenantId is already TenantId-branded.
|
|
48
|
+
const tenantId = (override ?? event.user.tenantId) as TenantId;
|
|
49
|
+
// With an override the executor user has to move to the target tenant too,
|
|
50
|
+
// otherwise the event-store stream lookup runs against event.user.tenantId
|
|
51
|
+
// and reports a version conflict although the projection row exists.
|
|
52
|
+
const executorUser = override !== undefined ? { ...event.user, tenantId } : event.user;
|
|
53
|
+
|
|
54
|
+
const existing = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
55
|
+
tenantId,
|
|
56
|
+
slug: event.payload.slug,
|
|
57
|
+
kind: TEXT_BLOCK_KIND,
|
|
58
|
+
locale: event.payload.locale,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const fields = {
|
|
62
|
+
slug: event.payload.slug,
|
|
63
|
+
kind: TEXT_BLOCK_KIND,
|
|
64
|
+
locale: event.payload.locale,
|
|
65
|
+
title: event.payload.title,
|
|
66
|
+
content: event.payload.content,
|
|
67
|
+
contentFormat: event.payload.contentFormat,
|
|
68
|
+
folder: event.payload.folder ?? null,
|
|
69
|
+
variableSchema: "{}",
|
|
70
|
+
linkedResources: "{}",
|
|
71
|
+
scope: tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const),
|
|
72
|
+
parentTemplateId: null,
|
|
73
|
+
// Text blocks have no draft stage — saving publishes, so resolveTemplate
|
|
74
|
+
// (which only returns active rows) finds them.
|
|
75
|
+
status: "active" as const,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (existing) {
|
|
79
|
+
const result = await executor.update(
|
|
80
|
+
{
|
|
81
|
+
id: existing.id,
|
|
82
|
+
version: existing.version,
|
|
83
|
+
// Only the editable columns — slug/kind/locale are the unique key,
|
|
84
|
+
// and rewriting scope/status/variableSchema here would reset whatever
|
|
85
|
+
// the template upserts put on the row.
|
|
86
|
+
changes: {
|
|
87
|
+
title: fields.title,
|
|
88
|
+
content: fields.content,
|
|
89
|
+
contentFormat: fields.contentFormat,
|
|
90
|
+
folder: fields.folder,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
executorUser,
|
|
94
|
+
db,
|
|
95
|
+
);
|
|
96
|
+
if (!result.isSuccess) return result;
|
|
97
|
+
return {
|
|
98
|
+
isSuccess: true as const,
|
|
99
|
+
data: { slug: event.payload.slug, locale: event.payload.locale, isNew: false },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await executor.create({ ...fields, tenantId }, executorUser, db);
|
|
104
|
+
if (!result.isSuccess) return result;
|
|
105
|
+
return {
|
|
106
|
+
isSuccess: true as const,
|
|
107
|
+
data: { slug: event.payload.slug, locale: event.payload.locale, isNew: true },
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { CONTENT_FORMATS,
|
|
3
|
+
import { CONTENT_FORMATS, TEMPLATE_STATUSES, UPSERT_KINDS } from "../constants";
|
|
4
4
|
import { templateResourceEntity, templateResourcesTable } from "../table";
|
|
5
5
|
|
|
6
6
|
// Single executor pro Bundle — Pattern aus text-content. Wird von allen
|
|
@@ -23,7 +23,16 @@ export const localeSchema = z
|
|
|
23
23
|
.max(8)
|
|
24
24
|
.regex(/^[a-z]{2}(-[a-z]{2})?$/i, "locale must be ISO 639-1 (e.g. de, en, en-us)");
|
|
25
25
|
|
|
26
|
-
export const kindSchema = z.enum(
|
|
26
|
+
export const kindSchema = z.enum(UPSERT_KINDS);
|
|
27
|
+
|
|
28
|
+
// Folder path for the content tree: kebab segments joined by `/`.
|
|
29
|
+
export const folderSchema = z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(128)
|
|
33
|
+
.regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
|
|
34
|
+
|
|
35
|
+
export const titleSchema = z.string().min(1).max(200);
|
|
27
36
|
export const contentFormatSchema = z.enum(CONTENT_FORMATS);
|
|
28
37
|
export const statusSchema = z.enum(TEMPLATE_STATUSES);
|
|
29
38
|
|
|
@@ -38,4 +47,6 @@ export const upsertPayloadSchema = z.object({
|
|
|
38
47
|
variableSchema: z.record(z.string(), z.unknown()).default({}),
|
|
39
48
|
linkedResources: z.record(z.string(), z.string()).default({}),
|
|
40
49
|
parentTemplateId: z.string().min(1).optional(),
|
|
50
|
+
title: titleSchema.nullable().optional(),
|
|
51
|
+
folder: folderSchema.nullable().optional(),
|
|
41
52
|
});
|
|
@@ -41,6 +41,8 @@ export const upsertSystemWrite = defineWriteHandler({
|
|
|
41
41
|
contentFormat: event.payload.contentFormat,
|
|
42
42
|
variableSchema: JSON.stringify(event.payload.variableSchema),
|
|
43
43
|
linkedResources: JSON.stringify(event.payload.linkedResources),
|
|
44
|
+
title: event.payload.title ?? null,
|
|
45
|
+
folder: event.payload.folder ?? null,
|
|
44
46
|
scope: "system" as const,
|
|
45
47
|
parentTemplateId: event.payload.parentTemplateId ?? null,
|
|
46
48
|
// System-Defaults sind sofort active (kein draft-Stage für seeds).
|
|
@@ -65,6 +65,8 @@ export const upsertTenantWrite = defineWriteHandler({
|
|
|
65
65
|
contentFormat: event.payload.contentFormat,
|
|
66
66
|
variableSchema: JSON.stringify(event.payload.variableSchema),
|
|
67
67
|
linkedResources: JSON.stringify(event.payload.linkedResources),
|
|
68
|
+
title: event.payload.title ?? null,
|
|
69
|
+
folder: event.payload.folder ?? null,
|
|
68
70
|
scope: "tenant" as const,
|
|
69
71
|
parentTemplateId: event.payload.parentTemplateId ?? null,
|
|
70
72
|
status: event.payload.status,
|
|
@@ -10,13 +10,20 @@ export {
|
|
|
10
10
|
CONTENT_FORMATS,
|
|
11
11
|
type ContentFormat,
|
|
12
12
|
FALLBACK_LOCALE,
|
|
13
|
+
NON_RENDER_KINDS,
|
|
14
|
+
type NonRenderKind,
|
|
13
15
|
RENDER_KINDS,
|
|
14
16
|
type RenderKind,
|
|
15
17
|
SYSTEM_TENANT_ID,
|
|
18
|
+
TEMPLATE_KINDS,
|
|
16
19
|
TEMPLATE_SCOPES,
|
|
17
20
|
TEMPLATE_STATUSES,
|
|
21
|
+
TEXT_BLOCK_KIND,
|
|
22
|
+
type TemplateKind,
|
|
18
23
|
type TemplateScope,
|
|
19
24
|
type TemplateStatus,
|
|
25
|
+
UPSERT_KINDS,
|
|
26
|
+
type UpsertKind,
|
|
20
27
|
} from "./constants";
|
|
21
28
|
export { createTemplateResolverFeature } from "./feature";
|
|
22
29
|
export {
|
|
@@ -7,11 +7,14 @@ export const TemplateResolverHandlers = {
|
|
|
7
7
|
upsertTenant: "template-resolver:write:upsert-tenant",
|
|
8
8
|
publish: "template-resolver:write:publish",
|
|
9
9
|
archive: "template-resolver:write:archive",
|
|
10
|
+
set: "template-resolver:write:set",
|
|
10
11
|
} as const;
|
|
11
12
|
|
|
12
13
|
export const TemplateResolverQueries = {
|
|
13
14
|
findById: "template-resolver:query:find-by-id",
|
|
14
15
|
list: "template-resolver:query:list",
|
|
16
|
+
bySlug: "template-resolver:query:by-slug",
|
|
17
|
+
byTenant: "template-resolver:query:by-tenant",
|
|
15
18
|
} as const;
|
|
16
19
|
|
|
17
20
|
export const TemplateResolverErrors = {
|
|
@@ -18,13 +18,13 @@ import {
|
|
|
18
18
|
type TenantId,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
|
|
21
|
-
import type
|
|
21
|
+
import { type ContentFormat, TEXT_BLOCK_KIND, type UpsertKind } from "./constants";
|
|
22
22
|
import { executor } from "./handlers/shared";
|
|
23
23
|
import { type TemplateResourceRow, templateResourcesTable } from "./table";
|
|
24
24
|
|
|
25
25
|
export type SeedSystemTemplateOptions = {
|
|
26
26
|
readonly slug: string;
|
|
27
|
-
readonly kind:
|
|
27
|
+
readonly kind: UpsertKind;
|
|
28
28
|
readonly locale: string;
|
|
29
29
|
readonly content: string;
|
|
30
30
|
readonly contentFormat: ContentFormat;
|
|
@@ -62,6 +62,8 @@ export async function seedSystemTemplate(
|
|
|
62
62
|
contentFormat: opts.contentFormat,
|
|
63
63
|
variableSchema,
|
|
64
64
|
linkedResources,
|
|
65
|
+
title: null,
|
|
66
|
+
folder: null,
|
|
65
67
|
scope: "system" as const,
|
|
66
68
|
parentTemplateId,
|
|
67
69
|
status: "active" as const,
|
|
@@ -94,3 +96,125 @@ export async function seedSystemTemplate(
|
|
|
94
96
|
},
|
|
95
97
|
});
|
|
96
98
|
}
|
|
99
|
+
|
|
100
|
+
export type SeedTextBlockOptions = {
|
|
101
|
+
readonly tenantId: TenantId;
|
|
102
|
+
readonly slug: string;
|
|
103
|
+
readonly locale: string;
|
|
104
|
+
readonly title: string;
|
|
105
|
+
readonly content?: string | null;
|
|
106
|
+
/** Folder path for the content tree; optional + null = root node. The seed
|
|
107
|
+
* path skips slug/folder validation (system-trusted), but app builders
|
|
108
|
+
* should stay kebab-only so set.write can overwrite the row later. */
|
|
109
|
+
readonly folder?: string | null;
|
|
110
|
+
readonly contentFormat?: ContentFormat;
|
|
111
|
+
readonly by?: SessionUser;
|
|
112
|
+
readonly ifExists?: SeedIfExists;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Boot/test seed for kind=text-block. Same projection path as set.write,
|
|
116
|
+
// without the access check. Default ifExists="skip".
|
|
117
|
+
export async function seedTextBlock(
|
|
118
|
+
db: DbConnection,
|
|
119
|
+
opts: SeedTextBlockOptions,
|
|
120
|
+
): Promise<{ id: string }> {
|
|
121
|
+
const by = opts.by ?? createSystemUser(opts.tenantId);
|
|
122
|
+
const tdb = createTenantDb(db, opts.tenantId, "system");
|
|
123
|
+
|
|
124
|
+
const existingRow = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
125
|
+
tenantId: opts.tenantId,
|
|
126
|
+
slug: opts.slug,
|
|
127
|
+
kind: TEXT_BLOCK_KIND,
|
|
128
|
+
locale: opts.locale,
|
|
129
|
+
});
|
|
130
|
+
const existing = existingRow
|
|
131
|
+
? { id: String(existingRow.id), version: existingRow.version }
|
|
132
|
+
: null;
|
|
133
|
+
|
|
134
|
+
const fields = {
|
|
135
|
+
slug: opts.slug,
|
|
136
|
+
kind: TEXT_BLOCK_KIND,
|
|
137
|
+
locale: opts.locale,
|
|
138
|
+
title: opts.title,
|
|
139
|
+
content: opts.content ?? null,
|
|
140
|
+
contentFormat: opts.contentFormat ?? ("markdown" as const),
|
|
141
|
+
folder: opts.folder ?? null,
|
|
142
|
+
variableSchema: "{}",
|
|
143
|
+
linkedResources: "{}",
|
|
144
|
+
scope: opts.tenantId === SYSTEM_TENANT_ID ? ("system" as const) : ("tenant" as const),
|
|
145
|
+
parentTemplateId: null,
|
|
146
|
+
status: "active" as const,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return runEventStoreSeed({
|
|
150
|
+
existing,
|
|
151
|
+
ifExists: opts.ifExists,
|
|
152
|
+
create: async () => {
|
|
153
|
+
const result = await executor.create({ ...fields, tenantId: opts.tenantId }, by, tdb);
|
|
154
|
+
if (!result.isSuccess) {
|
|
155
|
+
throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
|
|
156
|
+
}
|
|
157
|
+
// @cast-boundary db-row — executor.create returns the inserted row from
|
|
158
|
+
// INSERT/RETURNING; id is guaranteed by the PK in the returning clause.
|
|
159
|
+
const data = result.data as Partial<TemplateResourceRow>;
|
|
160
|
+
if (data.id === undefined) {
|
|
161
|
+
throw new Error("seedTextBlock: executor.create did not return an id");
|
|
162
|
+
}
|
|
163
|
+
return { id: String(data.id) };
|
|
164
|
+
},
|
|
165
|
+
update: async (row) => {
|
|
166
|
+
// Skip no-op updates so a legal re-seed on every pod boot does not bump
|
|
167
|
+
// version/updatedAt (ETag, "last changed", event-store growth).
|
|
168
|
+
if (
|
|
169
|
+
existingRow?.title === fields.title &&
|
|
170
|
+
existingRow?.content === fields.content &&
|
|
171
|
+
existingRow?.folder === fields.folder
|
|
172
|
+
) {
|
|
173
|
+
return { id: String(row.id) };
|
|
174
|
+
}
|
|
175
|
+
const result = await executor.update(
|
|
176
|
+
{
|
|
177
|
+
id: row.id,
|
|
178
|
+
version: row.version,
|
|
179
|
+
changes: { title: fields.title, content: fields.content, folder: fields.folder },
|
|
180
|
+
},
|
|
181
|
+
by,
|
|
182
|
+
tdb,
|
|
183
|
+
);
|
|
184
|
+
if (!result.isSuccess) {
|
|
185
|
+
throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
|
|
186
|
+
}
|
|
187
|
+
return { id: String(row.id) };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type LegalContentBlock = {
|
|
193
|
+
readonly slug: string;
|
|
194
|
+
readonly locale: string;
|
|
195
|
+
readonly title: string;
|
|
196
|
+
readonly content: string;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Boot seed for legal/marketing copy from a JSON template. `ifExists: "update"`
|
|
200
|
+
// is load-bearing: the template is authoritative — otherwise legally required
|
|
201
|
+
// additions never reach rows that were seeded once.
|
|
202
|
+
export async function seedLegalContentFromJson(
|
|
203
|
+
db: DbConnection,
|
|
204
|
+
blocks: readonly LegalContentBlock[],
|
|
205
|
+
opts: { readonly tenantId?: TenantId; readonly by?: SessionUser } = {},
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const tenantId = opts.tenantId ?? (SYSTEM_TENANT_ID as TenantId);
|
|
208
|
+
const by = opts.by ?? createSystemUser(tenantId);
|
|
209
|
+
for (const block of blocks) {
|
|
210
|
+
await seedTextBlock(db, {
|
|
211
|
+
tenantId,
|
|
212
|
+
slug: block.slug,
|
|
213
|
+
locale: block.locale,
|
|
214
|
+
title: block.title,
|
|
215
|
+
content: block.content,
|
|
216
|
+
ifExists: "update",
|
|
217
|
+
by,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
createSelectField,
|
|
6
6
|
createTextField,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { CONTENT_FORMATS,
|
|
8
|
+
import { CONTENT_FORMATS, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
|
|
9
9
|
|
|
10
10
|
// TemplateResource — strukturierte Template-Definition mit Tenant-
|
|
11
11
|
// Override-Hierarchie, Locale-Fallback und Resource-Linking via
|
|
@@ -19,8 +19,15 @@ export const templateResourceEntity = createEntity({
|
|
|
19
19
|
table: "read_template_resources",
|
|
20
20
|
fields: {
|
|
21
21
|
slug: createTextField({ required: true }),
|
|
22
|
-
kind: createSelectField({ required: true, options: [...
|
|
22
|
+
kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
|
|
23
23
|
locale: createTextField({ required: true }),
|
|
24
|
+
// Human-facing heading for kind="text-block" (legal pages, marketing
|
|
25
|
+
// copy). Nullable because render templates carry their subject inside
|
|
26
|
+
// the body; a NOT NULL here would break every existing mail row.
|
|
27
|
+
title: createTextField({}),
|
|
28
|
+
// Folder path for the content tree, `/`-separated ("page/marketing").
|
|
29
|
+
// null = root node.
|
|
30
|
+
folder: createTextField({}),
|
|
24
31
|
// Template-body is authored by TenantAdmin/Operator (email-templates etc.),
|
|
25
32
|
// business data — kein end-user UGC.
|
|
26
33
|
content: createLongTextField({ allowPlaintext: "is-business-data" }),
|
|
@@ -52,6 +59,8 @@ export type TemplateResourceRow = {
|
|
|
52
59
|
readonly slug: string;
|
|
53
60
|
readonly kind: string;
|
|
54
61
|
readonly locale: string;
|
|
62
|
+
readonly title: string | null;
|
|
63
|
+
readonly folder: string | null;
|
|
55
64
|
readonly content: string | null;
|
|
56
65
|
readonly contentFormat: string;
|
|
57
66
|
readonly variableSchema: string | null;
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import { textBlocksClient } from "../client-plugin";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Covers the three new migration paths (advisor gap): navId attach + SSE
|
|
6
|
+
// entities, no-leak without navId (conditional spread), and the unwrap (the
|
|
7
|
+
// provider emits the folder/leaves directly, NOT under the "Content" wrapper).
|
|
8
|
+
// The provider fetches → fetch is mocked.
|
|
9
9
|
|
|
10
|
-
describe("
|
|
10
|
+
describe("textBlocksClient — shape", () => {
|
|
11
11
|
test("ohne navId: kein navProvider/navEntities (no-leak), aber Resolver bleibt", () => {
|
|
12
|
-
const def =
|
|
13
|
-
expect(def.name).toBe("
|
|
12
|
+
const def = textBlocksClient();
|
|
13
|
+
expect(def.name).toBe("template-resolver");
|
|
14
14
|
expect(def.navProviders).toBeUndefined();
|
|
15
15
|
expect(def.navEntities).toBeUndefined();
|
|
16
|
-
expect(def.resolvers?.["
|
|
16
|
+
expect(def.resolvers?.["template-resolver:edit"]).toBeDefined();
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test("mit navId: Provider + SSE-Entities unter exakt dieser QN", () => {
|
|
20
20
|
const navId = "publicstatus:nav:content";
|
|
21
|
-
const def =
|
|
21
|
+
const def = textBlocksClient({ navId });
|
|
22
22
|
expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
|
|
23
|
-
expect(def.navEntities?.[navId]).toEqual(["
|
|
23
|
+
expect(def.navEntities?.[navId]).toEqual(["template-resource"]);
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
describe("
|
|
27
|
+
describe("textBlocksClient — Provider unwrappt den Content-Container", () => {
|
|
28
28
|
const origFetch = globalThis.fetch;
|
|
29
29
|
afterEach(() => {
|
|
30
30
|
globalThis.fetch = origFetch;
|
|
@@ -32,11 +32,18 @@ describe("textContentClient — Provider unwrappt den Content-Container", () =>
|
|
|
32
32
|
|
|
33
33
|
test("emittiert Folder/Leaves direkt, kein 'Content'-Wrapper-Knoten", async () => {
|
|
34
34
|
const blocks = [
|
|
35
|
-
{
|
|
36
|
-
|
|
35
|
+
{
|
|
36
|
+
slug: "imprint",
|
|
37
|
+
locale: "de",
|
|
38
|
+
title: "Imprint",
|
|
39
|
+
content: "x",
|
|
40
|
+
folder: null,
|
|
41
|
+
updatedAt: "",
|
|
42
|
+
},
|
|
43
|
+
{ slug: "hero", locale: "de", title: "Hero", content: null, folder: "page", updatedAt: "" },
|
|
37
44
|
];
|
|
38
|
-
// Test-
|
|
39
|
-
// (preconnect etc.) —
|
|
45
|
+
// Test-mock boundary: bun's mock doesn't cover the full fetch signature
|
|
46
|
+
// (preconnect etc.) — double-cast is deliberate, only this test calls fetch.
|
|
40
47
|
globalThis.fetch = mock(
|
|
41
48
|
async () =>
|
|
42
49
|
new Response(JSON.stringify({ data: { blocks } }), {
|
|
@@ -46,19 +53,19 @@ describe("textContentClient — Provider unwrappt den Content-Container", () =>
|
|
|
46
53
|
) as unknown as typeof fetch;
|
|
47
54
|
|
|
48
55
|
const navId = "x:nav:content";
|
|
49
|
-
const provider =
|
|
56
|
+
const provider = textBlocksClient({ navId }).navProviders?.[navId];
|
|
50
57
|
if (provider === undefined) throw new Error("provider missing");
|
|
51
58
|
|
|
52
59
|
let emitted: readonly TreeNode[] | undefined;
|
|
53
60
|
provider()((nodes) => {
|
|
54
61
|
emitted = nodes;
|
|
55
62
|
});
|
|
56
|
-
// fetch().then(...)
|
|
63
|
+
// fetch().then(...) is async → wait one macrotask until emit has run.
|
|
57
64
|
await new Promise((r) => setTimeout(r, 0));
|
|
58
65
|
|
|
59
66
|
expect(emitted).toBeDefined();
|
|
60
67
|
const labels = (emitted ?? []).map((n) => n.label).sort();
|
|
61
|
-
//
|
|
68
|
+
// No "Content" wrapper — root-leaf "Imprint" + folder "page" directly.
|
|
62
69
|
expect(labels).not.toContain("Content");
|
|
63
70
|
expect(labels).toEqual(["Imprint", "page"]);
|
|
64
71
|
});
|