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