@cosmicdrift/kumiko-bundled-features 0.2.3 → 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.
- package/CHANGELOG.md +109 -0
- package/package.json +19 -14
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -6
- package/src/compliance-profiles/handlers/needs-profile.query.ts +1 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +6 -8
- package/src/compliance-profiles/resolve-for-tenant.ts +7 -5
- package/src/compliance-profiles/seeding.ts +1 -1
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/_internal/parse-override.ts +3 -2
- package/src/data-retention/handlers/policy-for.query.ts +1 -1
- package/src/data-retention/keep-for.ts +1 -1
- package/src/data-retention/presets.ts +1 -1
- package/src/data-retention/resolve-for-tenant.ts +1 -1
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +7 -5
- package/src/delivery/index.ts +0 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +1 -1
- package/src/file-provider-s3/feature.ts +2 -2
- package/src/files-provider-s3/s3-provider.ts +2 -2
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +82 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +189 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +69 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +58 -0
- package/src/text-content/handlers/set.write.ts +24 -1
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/feature.ts +8 -8
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/feature.ts +4 -3
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +8 -11
- package/src/user-data-rights/handlers/download-by-token.query.ts +14 -16
- package/src/user-data-rights/handlers/export-status.query.ts +1 -1
- package/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
- package/src/user-data-rights/handlers/request-export.write.ts +2 -2
- package/src/user-data-rights/run-export-jobs.ts +2 -2
- package/src/user-data-rights/run-forget-cleanup.ts +27 -28
- package/src/user-data-rights/run-user-export.ts +1 -1
- package/src/user-data-rights/token-helpers.ts +2 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +1 -1
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +1 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createTemplateResolverApi,
|
|
3
|
+
type ResolveRequest,
|
|
4
|
+
requireTemplateResolver,
|
|
5
|
+
TemplateNotFoundError,
|
|
6
|
+
type TemplateResolverApi,
|
|
7
|
+
type TemplateResource,
|
|
8
|
+
} from "./api";
|
|
9
|
+
export {
|
|
10
|
+
CONTENT_FORMATS,
|
|
11
|
+
type ContentFormat,
|
|
12
|
+
FALLBACK_LOCALE,
|
|
13
|
+
RENDER_KINDS,
|
|
14
|
+
type RenderKind,
|
|
15
|
+
SYSTEM_TENANT_ID,
|
|
16
|
+
TEMPLATE_SCOPES,
|
|
17
|
+
TEMPLATE_STATUSES,
|
|
18
|
+
type TemplateScope,
|
|
19
|
+
type TemplateStatus,
|
|
20
|
+
} from "./constants";
|
|
21
|
+
export { createTemplateResolverFeature } from "./feature";
|
|
22
|
+
export {
|
|
23
|
+
TEMPLATE_RESOLVER_FEATURE,
|
|
24
|
+
TemplateResolverErrors,
|
|
25
|
+
TemplateResolverHandlers,
|
|
26
|
+
TemplateResolverQueries,
|
|
27
|
+
} from "./qualified-names";
|
|
28
|
+
export { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "./table";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Feature name + qualified handler/query names (QN: scope:type:name).
|
|
3
|
+
export const TEMPLATE_RESOLVER_FEATURE = "template-resolver" as const;
|
|
4
|
+
|
|
5
|
+
export const TemplateResolverHandlers = {
|
|
6
|
+
upsertSystem: "template-resolver:write:upsert-system",
|
|
7
|
+
upsertTenant: "template-resolver:write:upsert-tenant",
|
|
8
|
+
publish: "template-resolver:write:publish",
|
|
9
|
+
archive: "template-resolver:write:archive",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const TemplateResolverQueries = {
|
|
13
|
+
findById: "template-resolver:query:find-by-id",
|
|
14
|
+
list: "template-resolver:query:list",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const TemplateResolverErrors = {
|
|
18
|
+
notFound: "template_resource_not_found",
|
|
19
|
+
invalidSlug: "invalid_slug",
|
|
20
|
+
invalidLocale: "invalid_locale",
|
|
21
|
+
systemAdminRequired: "system_admin_required",
|
|
22
|
+
alreadyActive: "template_already_active",
|
|
23
|
+
alreadyArchived: "template_already_archived",
|
|
24
|
+
} as const;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createLongTextField,
|
|
5
|
+
createSelectField,
|
|
6
|
+
createTextField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
|
|
9
|
+
|
|
10
|
+
// TemplateResource — strukturierte Template-Definition mit Tenant-
|
|
11
|
+
// Override-Hierarchie, Locale-Fallback und Resource-Linking via
|
|
12
|
+
// file-foundation. Pro (tenantId, slug, kind, locale) genau eine Row;
|
|
13
|
+
// scope/status/version differenzieren Lifecycle.
|
|
14
|
+
//
|
|
15
|
+
// `variableSchema` + `linkedResources` sind JSON-Strings in longText
|
|
16
|
+
// (Pattern aus compliance-profiles — kein dedizierter jsonbField im
|
|
17
|
+
// createEntity-DSL). App-Layer parsed JSON, persistiert wieder als String.
|
|
18
|
+
export const templateResourceEntity = createEntity({
|
|
19
|
+
table: "read_template_resources",
|
|
20
|
+
fields: {
|
|
21
|
+
slug: createTextField({ required: true }),
|
|
22
|
+
kind: createSelectField({ required: true, options: [...RENDER_KINDS] }),
|
|
23
|
+
locale: createTextField({ required: true }),
|
|
24
|
+
content: createLongTextField({}),
|
|
25
|
+
contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
|
|
26
|
+
variableSchema: createLongTextField({}),
|
|
27
|
+
linkedResources: createLongTextField({}),
|
|
28
|
+
scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
|
|
29
|
+
parentTemplateId: createTextField({}),
|
|
30
|
+
status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
|
|
31
|
+
},
|
|
32
|
+
indexes: [
|
|
33
|
+
{
|
|
34
|
+
unique: true,
|
|
35
|
+
columns: ["tenantId", "slug", "kind", "locale"],
|
|
36
|
+
name: "read_template_resources_unique",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const templateResourcesTable = buildDrizzleTable(
|
|
42
|
+
"template-resource",
|
|
43
|
+
templateResourceEntity,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Concrete Row-Type — single-source dafür dass die unknown-Werte die
|
|
47
|
+
// Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
|
|
48
|
+
// werden (statt 12× `row["x"] as Y` Casts in Handlern + Resolver).
|
|
49
|
+
export type TemplateResourceRow = {
|
|
50
|
+
readonly id: string | number;
|
|
51
|
+
readonly version: number;
|
|
52
|
+
readonly tenantId: string;
|
|
53
|
+
readonly slug: string;
|
|
54
|
+
readonly kind: string;
|
|
55
|
+
readonly locale: string;
|
|
56
|
+
readonly content: string | null;
|
|
57
|
+
readonly contentFormat: string;
|
|
58
|
+
readonly variableSchema: string | null;
|
|
59
|
+
readonly linkedResources: string | null;
|
|
60
|
+
readonly scope: string;
|
|
61
|
+
readonly parentTemplateId: string | null;
|
|
62
|
+
readonly status: string;
|
|
63
|
+
readonly createdAt: Date;
|
|
64
|
+
readonly updatedAt: Date;
|
|
65
|
+
readonly createdBy: string;
|
|
66
|
+
readonly updatedBy: string;
|
|
67
|
+
};
|
|
@@ -14,6 +14,6 @@ export const activeTenantIdsQuery = defineQueryHandler({
|
|
|
14
14
|
.from(tenantTable)
|
|
15
15
|
.where(eq(tenantTable["isEnabled"], true));
|
|
16
16
|
|
|
17
|
-
return rows.map((row) => (row as DbRow)["id"] as number);
|
|
17
|
+
return rows.map((row) => (row as DbRow)["id"] as number); // @cast-boundary db-row
|
|
18
18
|
},
|
|
19
19
|
});
|
|
@@ -61,7 +61,7 @@ export const cancelInvitationWrite = defineWriteHandler({
|
|
|
61
61
|
const updateResult = await executor.update(
|
|
62
62
|
{
|
|
63
63
|
id: event.payload.invitationId,
|
|
64
|
-
version: invitation["version"] as number,
|
|
64
|
+
version: invitation["version"] as number, // @cast-boundary db-row
|
|
65
65
|
changes: { status: INVITATION_STATUS.cancelled },
|
|
66
66
|
},
|
|
67
67
|
event.user,
|
|
@@ -27,7 +27,7 @@ export const resolveUserIdsQuery = defineQueryHandler({
|
|
|
27
27
|
.select({ userId: tenantMembershipsTable.userId })
|
|
28
28
|
.from(tenantMembershipsTable)
|
|
29
29
|
.where(eq(tenantMembershipsTable.tenantId, tenantId));
|
|
30
|
-
return rows.map((r) => r["userId"] as number);
|
|
30
|
+
return rows.map((r) => r["userId"] as number); // @cast-boundary db-row
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (userId !== undefined) {
|
|
@@ -39,11 +39,11 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
39
39
|
// between this read and append) surfaces as version_conflict rather than
|
|
40
40
|
// silent overwrite. Per-membership parallelism is rare; if it happens,
|
|
41
41
|
// the client retries on the error.
|
|
42
|
-
const row = existing as DbRow;
|
|
42
|
+
const row = existing as DbRow; // @cast-boundary generic-record
|
|
43
43
|
const result = await executor.update(
|
|
44
44
|
{
|
|
45
|
-
id: row["id"] as string,
|
|
46
|
-
version: row["version"] as number,
|
|
45
|
+
id: row["id"] as string, // @cast-boundary db-row
|
|
46
|
+
version: row["version"] as number, // @cast-boundary db-row
|
|
47
47
|
changes: { roles: JSON.stringify(event.payload.roles) },
|
|
48
48
|
},
|
|
49
49
|
event.user,
|
|
@@ -412,4 +412,58 @@ describe("text-content :: seedTextBlock", () => {
|
|
|
412
412
|
});
|
|
413
413
|
expect(a.id).toBe(b.id);
|
|
414
414
|
});
|
|
415
|
+
|
|
416
|
+
// Drift-Documentation: seedTextBlock geht direkt durch den Executor
|
|
417
|
+
// OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
|
|
418
|
+
// Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
|
|
419
|
+
// Plattform-Seeds nutzen das für "page:index:hero.title" etc.), aber
|
|
420
|
+
// ein User-Edit derselben Block über set.write würde mit
|
|
421
|
+
// validation_error fail (regex `^[a-z0-9][a-z0-9-]*$`). Drift ist
|
|
422
|
+
// **bewusst** in V.1.3 — seedTextBlock ist system-trusted (boot-fixture,
|
|
423
|
+
// kein User-Input). V.1.4 plant ein echtes `folder`-Field statt
|
|
424
|
+
// `:`-Separator-im-Slug, dann fällt die Drift weg.
|
|
425
|
+
//
|
|
426
|
+
// Dieser Test pinnt den Status quo: Editor-Form via set.write rejected
|
|
427
|
+
// ":"-Slugs auch wenn seedTextBlock sie angelegt hat. Plus-Test
|
|
428
|
+
// verhindert dass jemand silent seedTextBlock-Validation hinzufügt
|
|
429
|
+
// ohne app-side seed-Slugs (z.B. legal-pages-Plattform-Seeds) zu
|
|
430
|
+
// konvertieren.
|
|
431
|
+
test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
|
|
432
|
+
// Seed mit `:`-Slug funktioniert (legal-pages-Pattern)
|
|
433
|
+
const seeded = await seedTextBlock(db, {
|
|
434
|
+
tenantId: tenantAdmin.tenantId,
|
|
435
|
+
slug: "page:hero",
|
|
436
|
+
lang: "de",
|
|
437
|
+
title: "Seeded",
|
|
438
|
+
body: "from-seed",
|
|
439
|
+
});
|
|
440
|
+
expect(seeded.id).toBeDefined();
|
|
441
|
+
|
|
442
|
+
// Set.write auf demselben Slug → validation_error (kebab-only regex)
|
|
443
|
+
const error = await stack.http.writeErr(
|
|
444
|
+
TextContentHandlers.set,
|
|
445
|
+
{ slug: "page:hero", lang: "de", title: "User-edit", body: "from-write" },
|
|
446
|
+
tenantAdmin,
|
|
447
|
+
);
|
|
448
|
+
expectErrorIncludes(error, "validation_error");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
|
|
452
|
+
// Inverse-Test: für kebab-only Slugs (`page-hero`) klappen beide
|
|
453
|
+
// Pfade. App-Builder die Edit-Form-fähige Seeds wollen, müssen
|
|
454
|
+
// kebab-only verwenden (siehe publicstatus/bin/seed-demo.ts).
|
|
455
|
+
await seedTextBlock(db, {
|
|
456
|
+
tenantId: tenantAdmin.tenantId,
|
|
457
|
+
slug: "page-hero",
|
|
458
|
+
lang: "de",
|
|
459
|
+
title: "Seeded",
|
|
460
|
+
body: "from-seed",
|
|
461
|
+
});
|
|
462
|
+
const result = await stack.http.writeOk<{ slug: string; lang: string }>(
|
|
463
|
+
TextContentHandlers.set,
|
|
464
|
+
{ slug: "page-hero", lang: "de", title: "User-edit", body: "from-write" },
|
|
465
|
+
tenantAdmin,
|
|
466
|
+
);
|
|
467
|
+
expect(result.slug).toBe("page-hero");
|
|
468
|
+
});
|
|
415
469
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @runtime client
|
|
1
2
|
// Feature name
|
|
2
3
|
export const TEXT_CONTENT_FEATURE = "text-content" as const;
|
|
3
4
|
|
|
@@ -9,6 +10,7 @@ export const TextContentHandlers = {
|
|
|
9
10
|
// Qualified query handler names (QN format: scope:type:name)
|
|
10
11
|
export const TextContentQueries = {
|
|
11
12
|
bySlug: "text-content:query:by-slug",
|
|
13
|
+
byTenant: "text-content:query:by-tenant",
|
|
12
14
|
} as const;
|
|
13
15
|
|
|
14
16
|
// Error codes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineFeature
|
|
1
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { bySlugQuery } from "./handlers/by-slug.query";
|
|
3
|
+
import { byTenantQuery } from "./handlers/by-tenant.query";
|
|
3
4
|
import { setWrite } from "./handlers/set.write";
|
|
4
5
|
import { textBlockEntity } from "./table";
|
|
5
6
|
|
|
@@ -11,8 +12,16 @@ import { textBlockEntity } from "./table";
|
|
|
11
12
|
//
|
|
12
13
|
// Opt-in: wer keine statischen Texte braucht (interne Tools), aktiviert
|
|
13
14
|
// das Feature gar nicht. Wer es aktiviert, hat sofort CRUD + by-slug-
|
|
14
|
-
// query — Routes/Render kommen pro Use-Case
|
|
15
|
-
|
|
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() {
|
|
16
25
|
return defineFeature("text-content", (r) => {
|
|
17
26
|
r.entity("text-block", textBlockEntity);
|
|
18
27
|
|
|
@@ -22,8 +31,15 @@ export function createTextContentFeature(): FeatureDefinition {
|
|
|
22
31
|
|
|
23
32
|
const queries = {
|
|
24
33
|
bySlug: r.queryHandler(bySlugQuery),
|
|
34
|
+
byTenant: r.queryHandler(byTenantQuery),
|
|
25
35
|
};
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
const treeHandle = r.treeActions({
|
|
38
|
+
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
39
|
+
list: {},
|
|
40
|
+
create: { args: { folder: "" as string } },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return { handlers, queries, treeHandle };
|
|
28
44
|
});
|
|
29
45
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { castTenantRows } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { type TextBlockRow, textBlocksTable } from "../table";
|
|
7
|
+
|
|
8
|
+
// Public-Read aller Text-Blocks für einen Tenant. Use-Case: Visual-Tree-
|
|
9
|
+
// Provider lädt die Slug-Liste zur Sidebar-Render. Anonymous: explizit
|
|
10
|
+
// in roles damit no-JWT-Visitors auch lesen können (Marketing-Sidebar
|
|
11
|
+
// auf Public-Pages). Tenant-Scope kommt aus query.user.tenantId; optional
|
|
12
|
+
// `tenantIdOverride` (SystemAdmin-only) — symmetrisch zu by-slug.query.
|
|
13
|
+
//
|
|
14
|
+
// **Listing statt single-row**: anders als by-slug returnt das hier
|
|
15
|
+
// `{ blocks: [...] }` mit allen Slugs des Tenants. Pro Slug nur die
|
|
16
|
+
// Summary-Felder (kein full body — den lädt der Editor on-demand via
|
|
17
|
+
// by-slug). Hält die Sidebar-Payload klein bei vielen Slugs.
|
|
18
|
+
export type TextBlockSummary = {
|
|
19
|
+
readonly slug: string;
|
|
20
|
+
readonly lang: string;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly body: 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 — nur für SystemAdmin. Symmetrisch
|
|
31
|
+
* zur by-slug.query und set.write Override-Logik. */
|
|
32
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
33
|
+
}),
|
|
34
|
+
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
35
|
+
handler: async (query, ctx) => {
|
|
36
|
+
const override = query.payload.tenantIdOverride;
|
|
37
|
+
if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
|
|
38
|
+
throw new AccessDeniedError({
|
|
39
|
+
i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
40
|
+
details: { reason: "tenant_override_requires_system_admin" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const tenantId = override ?? query.user.tenantId;
|
|
44
|
+
const rows = castTenantRows<TextBlockRow>(
|
|
45
|
+
await ctx.db.select().from(textBlocksTable).where(eq(textBlocksTable["tenantId"], tenantId)),
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
blocks: rows.map((row) => ({
|
|
49
|
+
slug: row.slug,
|
|
50
|
+
lang: row.lang,
|
|
51
|
+
title: row.title,
|
|
52
|
+
body: row.body,
|
|
53
|
+
folder: row.folder,
|
|
54
|
+
updatedAt: row.updatedAt,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -11,6 +11,17 @@ const slugSchema = z
|
|
|
11
11
|
.max(64)
|
|
12
12
|
.regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
|
|
13
13
|
|
|
14
|
+
// Folder-Convention V.1.4: gleiches kebab-Pattern wie slug, optional
|
|
15
|
+
// `/`-Separator für nested folders ("legal/imprint", "page/marketing").
|
|
16
|
+
// Multi-level wird vom Visual-Tree-Grouping flat-rendered bis V.1.5
|
|
17
|
+
// rekursive Hierarchie braucht — dann erweitert sich nur der UI-Render,
|
|
18
|
+
// nicht das Schema.
|
|
19
|
+
const folderSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(128)
|
|
23
|
+
.regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
|
|
24
|
+
|
|
14
25
|
const langSchema = z
|
|
15
26
|
.string()
|
|
16
27
|
.min(2)
|
|
@@ -36,6 +47,11 @@ export const setWrite = defineWriteHandler({
|
|
|
36
47
|
lang: langSchema,
|
|
37
48
|
title: z.string().min(1).max(200),
|
|
38
49
|
body: z.string().max(100_000).nullable(),
|
|
50
|
+
/** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null
|
|
51
|
+
* → root-node (kein Folder). Tree groupt nach diesem Field, slug
|
|
52
|
+
* bleibt flach + kebab-validiert. Beispiele: "page", "legal",
|
|
53
|
+
* "page/marketing". */
|
|
54
|
+
folder: folderSchema.nullable().optional(),
|
|
39
55
|
/** Optional cross-tenant write — nur für SystemAdmin. Typischer
|
|
40
56
|
* use-case: legal-pages-Edit-UI lässt SystemAdmin auf
|
|
41
57
|
* SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
|
|
@@ -68,7 +84,7 @@ export const setWrite = defineWriteHandler({
|
|
|
68
84
|
// Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
|
|
69
85
|
// SYSTEM_TENANT) als by verwendet.
|
|
70
86
|
const executorUser =
|
|
71
|
-
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
|
|
87
|
+
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
72
88
|
|
|
73
89
|
const existing = await fetchOne<TextBlockRow>(
|
|
74
90
|
db,
|
|
@@ -78,6 +94,11 @@ export const setWrite = defineWriteHandler({
|
|
|
78
94
|
eq(textBlocksTable["lang"], event.payload.lang),
|
|
79
95
|
);
|
|
80
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
|
+
|
|
81
102
|
if (existing) {
|
|
82
103
|
const result = await executor.update(
|
|
83
104
|
{
|
|
@@ -86,6 +107,7 @@ export const setWrite = defineWriteHandler({
|
|
|
86
107
|
changes: {
|
|
87
108
|
title: event.payload.title,
|
|
88
109
|
body: event.payload.body,
|
|
110
|
+
folder,
|
|
89
111
|
},
|
|
90
112
|
},
|
|
91
113
|
executorUser,
|
|
@@ -104,6 +126,7 @@ export const setWrite = defineWriteHandler({
|
|
|
104
126
|
lang: event.payload.lang,
|
|
105
127
|
title: event.payload.title,
|
|
106
128
|
body: event.payload.body,
|
|
129
|
+
folder,
|
|
107
130
|
tenantId,
|
|
108
131
|
},
|
|
109
132
|
executorUser,
|
|
@@ -24,6 +24,11 @@ export type SeedTextBlockOptions = {
|
|
|
24
24
|
readonly lang: string;
|
|
25
25
|
readonly title: string;
|
|
26
26
|
readonly body?: string | null;
|
|
27
|
+
/** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null =
|
|
28
|
+
* root-node. Seed-Pfad bypasst slugSchema/folderSchema-Validation
|
|
29
|
+
* (system-trusted), aber App-Builder sollten kebab-only nutzen damit
|
|
30
|
+
* set.write die geseedete Row später überschreiben kann. */
|
|
31
|
+
readonly folder?: string | null;
|
|
27
32
|
readonly by?: SessionUser;
|
|
28
33
|
};
|
|
29
34
|
|
|
@@ -50,12 +55,14 @@ export async function seedTextBlock(
|
|
|
50
55
|
eq(textBlocksTable["lang"], opts.lang),
|
|
51
56
|
);
|
|
52
57
|
|
|
58
|
+
const folder = opts.folder ?? null;
|
|
59
|
+
|
|
53
60
|
if (existing) {
|
|
54
61
|
const result = await executor.update(
|
|
55
62
|
{
|
|
56
63
|
id: existing.id,
|
|
57
64
|
version: existing.version,
|
|
58
|
-
changes: { title: opts.title, body: opts.body ?? null },
|
|
65
|
+
changes: { title: opts.title, body: opts.body ?? null, folder },
|
|
59
66
|
},
|
|
60
67
|
by,
|
|
61
68
|
tdb,
|
|
@@ -72,6 +79,7 @@ export async function seedTextBlock(
|
|
|
72
79
|
lang: opts.lang,
|
|
73
80
|
title: opts.title,
|
|
74
81
|
body: opts.body ?? null,
|
|
82
|
+
folder,
|
|
75
83
|
tenantId: opts.tenantId,
|
|
76
84
|
},
|
|
77
85
|
by,
|
|
@@ -16,6 +16,11 @@ export const textBlockEntity = createEntity({
|
|
|
16
16
|
lang: createTextField({ required: true }),
|
|
17
17
|
title: createTextField({ required: true }),
|
|
18
18
|
body: createTextField({}),
|
|
19
|
+
// V.1.4: explicit folder-Hierarchie statt `:`-Encoding im Slug.
|
|
20
|
+
// Visual-Tree gruppiert nach diesem Field; null/undefined → root.
|
|
21
|
+
// Pflicht-Constraint im set.write-Schema (kebab-only wie slug,
|
|
22
|
+
// damit App-Builder konsistente Naming-Conventions haben).
|
|
23
|
+
folder: createTextField({}),
|
|
19
24
|
},
|
|
20
25
|
indexes: [
|
|
21
26
|
{ unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
|
|
@@ -38,6 +43,7 @@ export type TextBlockRow = {
|
|
|
38
43
|
readonly lang: string;
|
|
39
44
|
readonly title: string;
|
|
40
45
|
readonly body: string | null;
|
|
46
|
+
readonly folder: string | null;
|
|
41
47
|
readonly createdAt: Date;
|
|
42
48
|
readonly updatedAt: Date;
|
|
43
49
|
readonly createdBy: string;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createStaticLocaleResolver,
|
|
5
|
+
LocaleProvider,
|
|
6
|
+
PrimitivesProvider,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
+
import { render, screen } from "@testing-library/react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { describe, expect, test, vi } from "vitest";
|
|
12
|
+
import { textContentClient } from "../client-plugin";
|
|
13
|
+
|
|
14
|
+
// Mock-Setup für die drei externen Hooks die TextContentEditor benutzt.
|
|
15
|
+
// vi.mock + vi.fn() erlaubt pro-Test verschiedene Roles + Dispatcher-
|
|
16
|
+
// Antworten. Memory `[Keine Fake-Tests]`: wir testen echtes Rendering,
|
|
17
|
+
// nicht nur die canWrite-Bedingung.
|
|
18
|
+
vi.mock("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
|
|
19
|
+
useShellUser: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@cosmicdrift/kumiko-renderer", async () => {
|
|
23
|
+
const actual = await vi.importActual<typeof import("@cosmicdrift/kumiko-renderer")>(
|
|
24
|
+
"@cosmicdrift/kumiko-renderer",
|
|
25
|
+
);
|
|
26
|
+
return {
|
|
27
|
+
...actual,
|
|
28
|
+
useDispatcher: vi.fn(() => ({
|
|
29
|
+
write: vi.fn(),
|
|
30
|
+
query: vi.fn(),
|
|
31
|
+
})),
|
|
32
|
+
useQuery: vi.fn(() => ({
|
|
33
|
+
data: { slug: "imprint", lang: "de", title: "Impressum", body: "Inhalt" },
|
|
34
|
+
loading: false,
|
|
35
|
+
error: null,
|
|
36
|
+
refetch: vi.fn(),
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
42
|
+
|
|
43
|
+
const TARGET = {
|
|
44
|
+
featureId: "text-content",
|
|
45
|
+
action: "edit",
|
|
46
|
+
args: { slug: "imprint", lang: "de" },
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
function getEditor() {
|
|
50
|
+
const def = textContentClient();
|
|
51
|
+
const Editor = def.resolvers?.["text-content:edit"];
|
|
52
|
+
if (!Editor) throw new Error("Editor not registered");
|
|
53
|
+
return Editor;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const localeResolver = createStaticLocaleResolver();
|
|
57
|
+
|
|
58
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
59
|
+
return (
|
|
60
|
+
<LocaleProvider resolver={localeResolver}>
|
|
61
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
62
|
+
</LocaleProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("TextContentEditor — role-based write-access", () => {
|
|
67
|
+
test("TenantAdmin sieht Save-Button + editable inputs", () => {
|
|
68
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
|
|
69
|
+
const Editor = getEditor();
|
|
70
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
71
|
+
|
|
72
|
+
// Save-Button gerendert (canWrite=true → Button.type=submit)
|
|
73
|
+
const saveButton = screen.getByRole("button", { name: /speichern/i });
|
|
74
|
+
expect(saveButton).toBeTruthy();
|
|
75
|
+
expect(saveButton.hasAttribute("disabled")).toBe(false);
|
|
76
|
+
|
|
77
|
+
// Read-only-Banner darf NICHT erscheinen
|
|
78
|
+
expect(screen.queryByText(/Read-only/)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("SystemAdmin sieht Save-Button (alternative write-role)", () => {
|
|
82
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["SystemAdmin"] });
|
|
83
|
+
const Editor = getEditor();
|
|
84
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
85
|
+
|
|
86
|
+
expect(screen.getByRole("button", { name: /speichern/i })).toBeTruthy();
|
|
87
|
+
expect(screen.queryByText(/Read-only/)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("Editor-Role sieht Read-only-Banner + KEIN Save-Button", () => {
|
|
91
|
+
// Das ist der advisor-flagged Pfad — bisher unverifiziert. Editor-
|
|
92
|
+
// Role hat in publicstatus's Schema Zugriff auf visual-Workspace +
|
|
93
|
+
// by-slug-query (read), aber NICHT auf set.write. UI muss das
|
|
94
|
+
// explizit kommunizieren statt 403 erst beim save zu zeigen.
|
|
95
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Editor"] });
|
|
96
|
+
const Editor = getEditor();
|
|
97
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
98
|
+
|
|
99
|
+
expect(screen.getByText(/Read-only/)).toBeTruthy();
|
|
100
|
+
expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("Admin-Role (publicstatus-Convention, ohne TenantAdmin-dual-Tag) sieht Read-only-Banner", () => {
|
|
104
|
+
// Apps die NUR `Admin` (ohne `TenantAdmin`) im JWT haben, kriegen
|
|
105
|
+
// read-only. Dokumentiert das Dual-Role-Pattern aus publicstatus:
|
|
106
|
+
// wer "Admin" allein hat (Memory `[Role-Naming-Drift]`), muss
|
|
107
|
+
// explizit auch "TenantAdmin" im JWT tragen damit der Editor
|
|
108
|
+
// schreiben darf.
|
|
109
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Admin"] });
|
|
110
|
+
const Editor = getEditor();
|
|
111
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
112
|
+
|
|
113
|
+
expect(screen.getByText(/Read-only/)).toBeTruthy();
|
|
114
|
+
expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("Logged-out (useShellUser=undefined) sieht Read-only-Banner", () => {
|
|
118
|
+
vi.mocked(useShellUser).mockReturnValue(undefined);
|
|
119
|
+
const Editor = getEditor();
|
|
120
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText(/Read-only/)).toBeTruthy();
|
|
123
|
+
expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|