@cosmicdrift/kumiko-bundled-features 0.2.3 → 0.3.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 +60 -0
- package/package.json +17 -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/feature.ts +1 -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 +42 -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-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/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/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -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,67 @@
|
|
|
1
|
+
// Webhook execution logic — separated from feature.ts so tests can stub
|
|
2
|
+
// the fetch without touching the MSP wiring.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export const webhookSpecSchema = z.object({
|
|
7
|
+
url: z.string(),
|
|
8
|
+
method: z.enum(["POST", "PUT", "PATCH"]),
|
|
9
|
+
headers: z.record(z.string(), z.string()),
|
|
10
|
+
body: z.unknown().optional(),
|
|
11
|
+
auth: z
|
|
12
|
+
.union([
|
|
13
|
+
z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
|
|
14
|
+
z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
|
|
15
|
+
])
|
|
16
|
+
.optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type WebhookSpec = z.infer<typeof webhookSpecSchema>;
|
|
20
|
+
|
|
21
|
+
export type WebhookDispatchResult =
|
|
22
|
+
| { readonly ok: true; readonly status: number }
|
|
23
|
+
| { readonly ok: false; readonly error: string };
|
|
24
|
+
|
|
25
|
+
// Resolves a secretRef via the test-injectable secret-store. Default
|
|
26
|
+
// implementation reads from process.env at the prefix WEBHOOK_SECRET_.
|
|
27
|
+
// Tests pass a custom resolver via setWebhookSecretResolver.
|
|
28
|
+
let secretResolver: (ref: string) => string | undefined = (ref) =>
|
|
29
|
+
process.env[`WEBHOOK_SECRET_${ref}`];
|
|
30
|
+
|
|
31
|
+
export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
|
|
32
|
+
secretResolver = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
36
|
+
|
|
37
|
+
export function setWebhookFetch(fn: typeof fetch): void {
|
|
38
|
+
fetchImpl = fn;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
|
|
42
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
|
|
43
|
+
if (spec.auth) {
|
|
44
|
+
const secret = secretResolver(spec.auth.secretRef);
|
|
45
|
+
if (!secret) {
|
|
46
|
+
return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
|
|
47
|
+
}
|
|
48
|
+
if (spec.auth.kind === "bearer") {
|
|
49
|
+
headers["authorization"] = `Bearer ${secret}`;
|
|
50
|
+
} else {
|
|
51
|
+
headers[spec.auth.name] = secret;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetchImpl(spec.url, {
|
|
56
|
+
method: spec.method,
|
|
57
|
+
headers,
|
|
58
|
+
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
return { ok: false, error: `HTTP ${res.status}: ${res.statusText}` };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, status: res.status };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -66,7 +66,7 @@ export function createMollieCheckoutSession(
|
|
|
66
66
|
tenantId: options.tenantId,
|
|
67
67
|
priceId: options.priceId,
|
|
68
68
|
},
|
|
69
|
-
}) as Promise<Payment>)) satisfies Payment;
|
|
69
|
+
}) as Promise<Payment>)) satisfies Payment; // @cast-boundary engine-bridge
|
|
70
70
|
|
|
71
71
|
const checkoutHref = payment.getCheckoutUrl();
|
|
72
72
|
if (!checkoutHref) {
|
|
@@ -102,7 +102,7 @@ export function verifyAndParseMollieWebhook(
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const metadata = (subscription.metadata as Record<string, string> | null) ?? {};
|
|
105
|
+
const metadata = (subscription.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
|
|
106
106
|
const tenantId = metadata["tenantId"];
|
|
107
107
|
if (!tenantId || tenantId.length === 0) return null;
|
|
108
108
|
const priceId = metadata["priceId"];
|
|
@@ -153,7 +153,7 @@ async function ensureSubscriptionForMandate(
|
|
|
153
153
|
): Promise<MollieSubscription | null> {
|
|
154
154
|
const customerId = payment.customerId;
|
|
155
155
|
if (!customerId) return null;
|
|
156
|
-
const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {};
|
|
156
|
+
const paymentMetadata = (payment.metadata as Record<string, string> | null) ?? {}; // @cast-boundary engine-bridge
|
|
157
157
|
const tenantId = paymentMetadata["tenantId"];
|
|
158
158
|
const priceId = paymentMetadata["priceId"];
|
|
159
159
|
if (!tenantId || !priceId) return null;
|
|
@@ -163,7 +163,7 @@ async function ensureSubscriptionForMandate(
|
|
|
163
163
|
const existing = await client.customerSubscriptions.list(customerId);
|
|
164
164
|
const matchingExisting = existing.find(
|
|
165
165
|
(sub) =>
|
|
166
|
-
(sub.metadata as Record<string, string> | null)?.["priceId"] === priceId &&
|
|
166
|
+
(sub.metadata as Record<string, string> | null)?.["priceId"] === priceId && // @cast-boundary engine-bridge
|
|
167
167
|
(sub.status === "active" || sub.status === "pending"),
|
|
168
168
|
);
|
|
169
169
|
if (matchingExisting) return matchingExisting;
|
|
@@ -185,8 +185,12 @@ export function extractMollieId(rawBody: string, headers: Record<string, string>
|
|
|
185
185
|
const contentType = headers["content-type"] ?? "";
|
|
186
186
|
if (contentType.includes("application/json")) {
|
|
187
187
|
try {
|
|
188
|
-
const parsed = JSON.parse(rawBody)
|
|
189
|
-
|
|
188
|
+
const parsed: unknown = JSON.parse(rawBody);
|
|
189
|
+
const id =
|
|
190
|
+
typeof parsed === "object" && parsed !== null && "id" in parsed
|
|
191
|
+
? (parsed as Record<string, unknown>)["id"] // @cast-boundary engine-payload
|
|
192
|
+
: undefined;
|
|
193
|
+
return typeof id === "string" ? id : null;
|
|
190
194
|
} catch {
|
|
191
195
|
return null;
|
|
192
196
|
}
|
|
@@ -203,15 +203,15 @@ async function extractSubscriptionFromEvent(
|
|
|
203
203
|
case StripeEventTypes.customerSubscriptionCreated:
|
|
204
204
|
case StripeEventTypes.customerSubscriptionUpdated:
|
|
205
205
|
case StripeEventTypes.customerSubscriptionDeleted:
|
|
206
|
-
return event.data.object as Stripe.Subscription;
|
|
206
|
+
return event.data.object as Stripe.Subscription; // @cast-boundary engine-bridge
|
|
207
207
|
case StripeEventTypes.invoicePaid:
|
|
208
208
|
case StripeEventTypes.invoicePaymentFailed: {
|
|
209
209
|
// Lazy-fetch der subscription. invoice.subscription ist eine
|
|
210
210
|
// string-id (Stripe-Webhooks expanden nicht auto). Wir holen das
|
|
211
211
|
// full subscription-Object damit der downstream-mapping
|
|
212
212
|
// (status, tier via priceId, period-end) konsistent funktioniert.
|
|
213
|
-
const invoice = event.data.object as Stripe.Invoice;
|
|
214
|
-
const subRef = (invoice as { subscription?: string | Stripe.Subscription | null })
|
|
213
|
+
const invoice = event.data.object as Stripe.Invoice; // @cast-boundary engine-bridge
|
|
214
|
+
const subRef = (invoice as { subscription?: string | Stripe.Subscription | null }) // @cast-boundary engine-payload
|
|
215
215
|
.subscription;
|
|
216
216
|
if (!subRef) {
|
|
217
217
|
// Invoice ohne subscription-reference (= one-shot-invoice, nicht
|
|
@@ -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,
|
|
@@ -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,56 @@
|
|
|
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 updatedAt: Date;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const byTenantQuery = defineQueryHandler({
|
|
27
|
+
name: "by-tenant",
|
|
28
|
+
schema: z.object({
|
|
29
|
+
/** Optional cross-tenant read — nur für SystemAdmin. Symmetrisch
|
|
30
|
+
* zur by-slug.query und set.write Override-Logik. */
|
|
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
|
+
if (override !== undefined && !query.user.roles.includes("SystemAdmin")) {
|
|
37
|
+
throw new AccessDeniedError({
|
|
38
|
+
i18nKey: "textContent.errors.tenantOverrideRequiresSystemAdmin",
|
|
39
|
+
details: { reason: "tenant_override_requires_system_admin" },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const tenantId = override ?? query.user.tenantId;
|
|
43
|
+
const rows = castTenantRows<TextBlockRow>(
|
|
44
|
+
await ctx.db.select().from(textBlocksTable).where(eq(textBlocksTable["tenantId"], tenantId)),
|
|
45
|
+
);
|
|
46
|
+
return {
|
|
47
|
+
blocks: rows.map((row) => ({
|
|
48
|
+
slug: row.slug,
|
|
49
|
+
lang: row.lang,
|
|
50
|
+
title: row.title,
|
|
51
|
+
body: row.body,
|
|
52
|
+
updatedAt: row.updatedAt,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -68,7 +68,7 @@ export const setWrite = defineWriteHandler({
|
|
|
68
68
|
// Symmetrisch zu seedTextBlock, das TestUsers.systemAdmin (tenantId =
|
|
69
69
|
// SYSTEM_TENANT) als by verwendet.
|
|
70
70
|
const executorUser =
|
|
71
|
-
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user;
|
|
71
|
+
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
72
72
|
|
|
73
73
|
const existing = await fetchOne<TextBlockRow>(
|
|
74
74
|
db,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
|
|
3
|
+
// in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
|
|
4
|
+
// und liefert den treeProvider der Text-Blocks aus der by-tenant Query
|
|
5
|
+
// lädt, nach Slug-Prefix gruppiert und als TreeNode[] emitted.
|
|
6
|
+
//
|
|
7
|
+
// **Slug-Gruppierung**: Slugs der Form `<prefix>:<rest>` oder `<prefix>/<rest>`
|
|
8
|
+
// werden unter einem `<prefix>`-Container-Knoten gruppiert. Slugs ohne
|
|
9
|
+
// Trenner landen als Top-Level-Knoten. Beispiele:
|
|
10
|
+
// - "page:index:hero.title" → folder "page", label "index:hero.title"
|
|
11
|
+
// - "imprint" → root-node, label "imprint"
|
|
12
|
+
// V.1.3+ kann mehrstufige Hierarchien einführen wenn realer Bedarf zeigt.
|
|
13
|
+
//
|
|
14
|
+
// **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
|
|
15
|
+
// "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
|
|
16
|
+
//
|
|
17
|
+
// **Fetch statt Subscribe**: V.1.1 ist Fetch-once beim Mount. Unsubscribe
|
|
18
|
+
// ist no-op. V.1.3+ kann SSE-driven Re-Emit einbauen wenn text-block-
|
|
19
|
+
// updated-Events propagiert werden.
|
|
20
|
+
|
|
21
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
23
|
+
import { TextContentQueries } from "../constants";
|
|
24
|
+
|
|
25
|
+
type BlockSummary = {
|
|
26
|
+
readonly slug: string;
|
|
27
|
+
readonly lang: string;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly body: string | null;
|
|
30
|
+
readonly updatedAt: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ByTenantResponse = {
|
|
34
|
+
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Folder-Name = alles vor dem ersten ":" oder "/", oder undefined wenn
|
|
38
|
+
// der Slug keinen Trenner enthält (dann landet er als Root-Node).
|
|
39
|
+
function getFolderName(slug: string): string | undefined {
|
|
40
|
+
const sepIdx = slug.search(/[:/]/);
|
|
41
|
+
if (sepIdx === -1) return undefined;
|
|
42
|
+
return slug.slice(0, sepIdx);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function groupBlocksBySlugPrefix(blocks: readonly BlockSummary[]): readonly TreeNode[] {
|
|
46
|
+
const rootNodes: TreeNode[] = [];
|
|
47
|
+
const folders = new Map<string, TreeNode[]>();
|
|
48
|
+
|
|
49
|
+
for (const block of blocks) {
|
|
50
|
+
const node: TreeNode = {
|
|
51
|
+
label: block.title || block.slug,
|
|
52
|
+
target: {
|
|
53
|
+
featureId: "text-content",
|
|
54
|
+
action: "edit",
|
|
55
|
+
args: { slug: block.slug, lang: block.lang },
|
|
56
|
+
},
|
|
57
|
+
state: block.body ? "filled" : "stub",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const folderName = getFolderName(block.slug);
|
|
61
|
+
if (folderName === undefined) {
|
|
62
|
+
rootNodes.push(node);
|
|
63
|
+
} else {
|
|
64
|
+
const existing = folders.get(folderName) ?? [];
|
|
65
|
+
existing.push(node);
|
|
66
|
+
folders.set(folderName, existing);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [name, children] of folders) {
|
|
71
|
+
rootNodes.push({
|
|
72
|
+
label: name,
|
|
73
|
+
icon: "folder",
|
|
74
|
+
state: "filled",
|
|
75
|
+
children,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return rootNodes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
|
|
83
|
+
fetch("/api/query", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
type: TextContentQueries.byTenant,
|
|
88
|
+
payload: {},
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
.then((r) => r.json())
|
|
92
|
+
.then((data: ByTenantResponse) => {
|
|
93
|
+
const nodes = groupBlocksBySlugPrefix(data.data.blocks);
|
|
94
|
+
emit(nodes);
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {
|
|
97
|
+
// V.1.3+ TODO: state="error"-Knoten + Reload-Action statt empty.
|
|
98
|
+
emit([]);
|
|
99
|
+
});
|
|
100
|
+
return () => {};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function textContentClient(): ClientFeatureDefinition {
|
|
104
|
+
return {
|
|
105
|
+
name: "text-content",
|
|
106
|
+
treeProvider,
|
|
107
|
+
treeActions: {
|
|
108
|
+
edit: { args: { slug: "" as string, lang: "" as string } },
|
|
109
|
+
list: {},
|
|
110
|
+
create: { args: { folder: "" as string } },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports für die Browser-Seite des text-content Features.
|
|
3
|
+
// Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
|
|
4
|
+
// konsumiert — die Server-Seite (defineFeature) lebt in
|
|
5
|
+
// `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
|
|
6
|
+
// React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
|
|
7
|
+
|
|
8
|
+
export { textContentClient } from "./client-plugin";
|
|
@@ -197,7 +197,7 @@ export function createTierEngineFeature<
|
|
|
197
197
|
// Invalidation: tier-assignment events update the cache.
|
|
198
198
|
r.entityHook("postSave", "tier-assignment", async (result) => {
|
|
199
199
|
// result.data has tenantId + tier (after entity-update merge)
|
|
200
|
-
const data = result.data as { tenantId?: unknown; tier?: unknown };
|
|
200
|
+
const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
|
|
201
201
|
// skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
|
|
202
202
|
// entity-events sind beide fields immer strings; ein malformed-payload
|
|
203
203
|
// (custom-handler-bug) würde hier silent zum cache-skip führen statt
|
|
@@ -210,7 +210,7 @@ export function createTierEngineFeature<
|
|
|
210
210
|
);
|
|
211
211
|
});
|
|
212
212
|
r.entityHook("postDelete", "tier-assignment", async (payload) => {
|
|
213
|
-
const data = payload.data as { tenantId?: unknown };
|
|
213
|
+
const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
|
|
214
214
|
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
215
215
|
if (typeof data.tenantId !== "string") return;
|
|
216
216
|
cache.delete(data.tenantId as TenantId);
|
|
@@ -235,16 +235,16 @@ export function createTierEngineFeature<
|
|
|
235
235
|
"tenant",
|
|
236
236
|
async (result, ctx) => {
|
|
237
237
|
// result-shape: kumiko-framework's SaveContext mit isNew + data
|
|
238
|
-
const saveResult = result as { isNew?: unknown; data?: unknown };
|
|
238
|
+
const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload
|
|
239
239
|
// skip: nur bei tenant-create (initial) — tenant-updates feuern
|
|
240
240
|
// auch postSave aber wir wollen kein neues tier-assignment bei
|
|
241
241
|
// re-keying oder name-update.
|
|
242
242
|
if (saveResult.isNew !== true) return;
|
|
243
|
-
const data = saveResult.data as { id?: unknown };
|
|
243
|
+
const data = saveResult.data as { id?: unknown }; // @cast-boundary engine-payload
|
|
244
244
|
// skip: defensive type-guard. Tenant-entity hat id zwingend, aber
|
|
245
245
|
// CrudExecutor's payload-shape ist runtime-unknown.
|
|
246
246
|
if (typeof data.id !== "string") return;
|
|
247
|
-
const newTenantId = data.id as TenantId;
|
|
247
|
+
const newTenantId = data.id as TenantId; // @cast-boundary engine-payload
|
|
248
248
|
const aggregateId = tierAssignmentAggregateId(newTenantId);
|
|
249
249
|
|
|
250
250
|
// skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
|
|
@@ -268,7 +268,7 @@ export function createTierEngineFeature<
|
|
|
268
268
|
// `as TenantDb` gegen future refactor.
|
|
269
269
|
// skip: defensive — sollte im inTransaction nie greifen.
|
|
270
270
|
if (!("raw" in ctx.db)) return;
|
|
271
|
-
const rawDb = ctx.db.raw as DbConnection;
|
|
271
|
+
const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
|
|
272
272
|
|
|
273
273
|
// Idempotency: stream-existence-check vor create. Pattern aus
|
|
274
274
|
// seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
|
|
@@ -276,7 +276,7 @@ export function createTierEngineFeature<
|
|
|
276
276
|
const [streamRow] = (await rawDb
|
|
277
277
|
.select({ v: maxFn(eventsTable.version) })
|
|
278
278
|
.from(eventsTable)
|
|
279
|
-
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
|
|
279
|
+
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[]; // @cast-boundary db-row
|
|
280
280
|
// skip: idempotency — aggregate-stream existiert schon (re-replay
|
|
281
281
|
// nach projection-rebuild oder hook-retry). create() würde
|
|
282
282
|
// version_conflict werfen + tenant-create rollback'n. Pattern aus
|
|
@@ -336,7 +336,7 @@ export function createTierEngineFeature<
|
|
|
336
336
|
// Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
|
|
337
337
|
// Bedürfnis entsteht.
|
|
338
338
|
type AssignmentRow = { tenantId: string; tier: string };
|
|
339
|
-
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
|
|
339
|
+
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[]; // @cast-boundary db-row
|
|
340
340
|
for (const row of rows) {
|
|
341
341
|
cache.set(
|
|
342
342
|
row.tenantId as TenantId,
|
|
@@ -30,7 +30,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
30
30
|
const condition =
|
|
31
31
|
query.payload.email !== undefined
|
|
32
32
|
? eq(userTable["email"], query.payload.email)
|
|
33
|
-
: eq(userTable["id"], query.payload.id as string);
|
|
33
|
+
: eq(userTable["id"], query.payload.id as string); // @cast-boundary engine-payload
|
|
34
34
|
|
|
35
35
|
const [row] = await ctx.db.select().from(userTable).where(condition).limit(1);
|
|
36
36
|
return (row as DbRow) ?? null;
|
package/src/user/seeding.ts
CHANGED
|
@@ -57,7 +57,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
|
|
|
57
57
|
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
58
58
|
|
|
59
59
|
const existing = await fetchOne(db, userTable, eq(userTable["email"], options.email));
|
|
60
|
-
if (existing) return existing["id"] as string;
|
|
60
|
+
if (existing) return existing["id"] as string; // @cast-boundary db-row
|
|
61
61
|
|
|
62
62
|
const result = await userExecutor.create(
|
|
63
63
|
{
|
|
@@ -86,7 +86,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
|
|
|
86
86
|
// als ehrlicher Throw rauskommt statt downstream als undefined-Bug.
|
|
87
87
|
function extractId(data: unknown, who: string): string {
|
|
88
88
|
if (typeof data === "object" && data !== null && "id" in data) {
|
|
89
|
-
const id = (data as { id: unknown }).id;
|
|
89
|
+
const id = (data as { id: unknown }).id; // @cast-boundary engine-bridge
|
|
90
90
|
if (typeof id === "string") return id;
|
|
91
91
|
}
|
|
92
92
|
throw new Error(`${who}: executor.create result has no string id (got ${JSON.stringify(data)})`);
|
|
@@ -238,7 +238,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
238
238
|
_userId: ctx._userId ?? SYSTEM_USER_ID,
|
|
239
239
|
};
|
|
240
240
|
await runExportJobs({
|
|
241
|
-
db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection,
|
|
241
|
+
db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator
|
|
242
242
|
registry: ctx.registry,
|
|
243
243
|
buildStorageProvider: async (tenantId) =>
|
|
244
244
|
createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"),
|
|
@@ -268,11 +268,12 @@ async function mapQueryResponseToRedirect(
|
|
|
268
268
|
): Promise<Response> {
|
|
269
269
|
if (!queryRes.ok) {
|
|
270
270
|
const errorBody = await queryRes.text();
|
|
271
|
-
|
|
271
|
+
const statusCode = queryRes.status as 400 | 401 | 404 | 410 | 500; // @cast-boundary engine-payload
|
|
272
|
+
return c.body(errorBody, statusCode, {
|
|
272
273
|
"content-type": queryRes.headers.get("content-type") ?? "application/json",
|
|
273
274
|
});
|
|
274
275
|
}
|
|
275
|
-
const body = (await queryRes.json()) as { data?: { url?: string } };
|
|
276
|
+
const body = (await queryRes.json()) as { data?: { url?: string } }; // @cast-boundary engine-payload
|
|
276
277
|
if (!body.data?.url) {
|
|
277
278
|
return c.json({ error: "download_resolution_failed" }, 500);
|
|
278
279
|
}
|
|
@@ -80,11 +80,11 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
80
80
|
ctx.db.raw,
|
|
81
81
|
exportJobsTable,
|
|
82
82
|
eq(exportJobsTable["id"], jobId),
|
|
83
|
-
)) as JobRow | null;
|
|
83
|
+
)) as JobRow | null; // @cast-boundary db-row
|
|
84
84
|
|
|
85
85
|
if (!jobRow || jobRow.userId !== userId) {
|
|
86
86
|
await recordInvalidAttempt({
|
|
87
|
-
db: ctx.db.raw as DbConnection,
|
|
87
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
88
88
|
tenantId,
|
|
89
89
|
now,
|
|
90
90
|
result: "notFound",
|
|
@@ -102,7 +102,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
102
102
|
|
|
103
103
|
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
104
104
|
await recordInvalidAttempt({
|
|
105
|
-
db: ctx.db.raw as DbConnection,
|
|
105
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
106
106
|
tenantId,
|
|
107
107
|
now,
|
|
108
108
|
result: "failed",
|
|
@@ -119,7 +119,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
119
119
|
}
|
|
120
120
|
if (!jobRow.downloadStorageKey) {
|
|
121
121
|
await recordInvalidAttempt({
|
|
122
|
-
db: ctx.db.raw as DbConnection,
|
|
122
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
123
123
|
tenantId,
|
|
124
124
|
now,
|
|
125
125
|
result: "expired",
|
|
@@ -142,7 +142,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
142
142
|
);
|
|
143
143
|
if (!provider.getSignedUrl) {
|
|
144
144
|
await recordInvalidAttempt({
|
|
145
|
-
db: ctx.db.raw as DbConnection,
|
|
145
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
146
146
|
tenantId,
|
|
147
147
|
now,
|
|
148
148
|
result: "signedUrlNotSupported",
|
|
@@ -177,20 +177,17 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
177
177
|
ctx.db.raw,
|
|
178
178
|
exportDownloadTokensTable,
|
|
179
179
|
eq(exportDownloadTokensTable["jobId"], jobId),
|
|
180
|
-
)) as TokenRow | null;
|
|
180
|
+
)) as TokenRow | null; // @cast-boundary db-row
|
|
181
181
|
|
|
182
182
|
if (tokenRow) {
|
|
183
183
|
await recordDownloadUse({
|
|
184
|
-
|
|
185
|
-
// Connection. Cast legit weil recordDownloadUse intern createTenantDb
|
|
186
|
-
// braucht.
|
|
187
|
-
db: ctx.db.raw as DbConnection,
|
|
184
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
188
185
|
tokenId: tokenRow.id,
|
|
189
186
|
tokenVersion: tokenRow.version,
|
|
190
187
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
191
188
|
tenantId: jobRow.requestedFromTenantId as Parameters<
|
|
192
189
|
typeof recordDownloadUse
|
|
193
|
-
>[0]["tenantId"],
|
|
190
|
+
>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
194
191
|
now,
|
|
195
192
|
ip: query.payload.auditMeta?.ip ?? null,
|
|
196
193
|
userAgent: query.payload.auditMeta?.userAgent ?? null,
|