@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,86 @@
|
|
|
1
|
+
# renderer-foundation
|
|
2
|
+
|
|
3
|
+
Plugin-Foundation für Renderer (Notification, HTML-Mail, PDF, Image). Plugins (`renderer-simple`, `renderer-mail-html`, `renderer-puppeteer-client`) registrieren sich via `r.useExtension("renderer", "<name>", { kinds, render })`.
|
|
4
|
+
|
|
5
|
+
**Plan-Doc:** [`kumiko-platform/docs/plans/features/renderer-foundation.md`](../../../../../../kumiko-platform/docs/plans/features/renderer-foundation.md)
|
|
6
|
+
|
|
7
|
+
## Mount
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {
|
|
11
|
+
collectRendererPlugins,
|
|
12
|
+
createRendererFoundationApi,
|
|
13
|
+
createRendererFoundationFeature,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
15
|
+
|
|
16
|
+
const features = [
|
|
17
|
+
createTemplateResolverFeature(),
|
|
18
|
+
createRendererFoundationFeature(),
|
|
19
|
+
createRendererSimpleFeature(), // Plugin
|
|
20
|
+
createRendererMailHtmlFeature(), // Plugin (enterprise)
|
|
21
|
+
// weitere Plugins...
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const app = createKumikoApp({
|
|
25
|
+
features,
|
|
26
|
+
extraContext: ({ registry }) => ({
|
|
27
|
+
rendererFoundation: createRendererFoundationApi(
|
|
28
|
+
collectRendererPlugins(registry),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Konsumtion
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { requireRendererFoundation } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
38
|
+
|
|
39
|
+
async function sendMail(ctx, tenantId) {
|
|
40
|
+
const foundation = requireRendererFoundation(ctx, "sendMail");
|
|
41
|
+
const renderer = foundation.createRendererForTenant({ tenantId, kind: "mail-html" });
|
|
42
|
+
const result = await renderer.render(
|
|
43
|
+
{
|
|
44
|
+
kind: "mail-html",
|
|
45
|
+
payload: { content: "Hello {{name}}", contentFormat: "markdown", variables: { name: "Frau Schmidt" } },
|
|
46
|
+
},
|
|
47
|
+
{ db: ctx.db, registry: ctx.registry, tenantId },
|
|
48
|
+
);
|
|
49
|
+
// result.html, result.text
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Plugins ohne Service-Deps (`renderer-simple`) ignorieren den zweiten `ctx`-Parameter.
|
|
54
|
+
|
|
55
|
+
## Plugin-Auswahl-Reihenfolge
|
|
56
|
+
|
|
57
|
+
1. Tenant-Override (Config-Key `rendererPluginByKind`, z.B. `{ "mail-html": "mail-html" }`)
|
|
58
|
+
2. `DEFAULT_PLUGIN_BY_KIND` aus constants
|
|
59
|
+
3. Erstes Plugin im Pool das das kind bedient
|
|
60
|
+
4. `RendererError("no_plugin_for_kind")` wenn nichts passt
|
|
61
|
+
|
|
62
|
+
## Eigenes Plugin schreiben
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
66
|
+
|
|
67
|
+
export const myRendererFeature = defineFeature("renderer-myown", (r) => {
|
|
68
|
+
r.requires("renderer-foundation");
|
|
69
|
+
r.useExtension("renderer", "myown", {
|
|
70
|
+
kinds: ["document-pdf"],
|
|
71
|
+
// ctx ist optional — nur nehmen wenn Service-Access nötig
|
|
72
|
+
render: async (req, ctx) => {
|
|
73
|
+
// ctx.db, ctx.registry, ctx.tenantId verfügbar
|
|
74
|
+
// eigene PDF-Logik
|
|
75
|
+
return { kind: "document-pdf", pdfBytes: ..., pageCount: 1, sizeBytes: ... };
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Out-of-Scope
|
|
82
|
+
|
|
83
|
+
- Template-Storage (kommt aus `template-resolver`)
|
|
84
|
+
- Resource-URL-Substitution (Caller-Verantwortung: signed-URL vs. data-URI je nach kind)
|
|
85
|
+
- Template-Authoring-UI — `designer`-Bundle (geplant)
|
|
86
|
+
- Mail-Versand — `delivery` + `mail-transport-smtp`
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { createRendererFoundationApi } from "../api";
|
|
4
|
+
import {
|
|
5
|
+
type RendererContext,
|
|
6
|
+
RendererError,
|
|
7
|
+
type RendererPlugin,
|
|
8
|
+
type RenderRequest,
|
|
9
|
+
type RenderResponse,
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
// Stub-Context für Plugin-Render-Calls in Unit-Tests. makePlugin ignoriert
|
|
13
|
+
// ctx; db+registry sind hier null-cast weil Unit-Tests keinen echten
|
|
14
|
+
// Stack haben — Integration-Tests (collect-plugins.integration.ts) nutzen
|
|
15
|
+
// echte stack.db / stack.registry. tenantId ist valid UUID (Memory-Lesson
|
|
16
|
+
// feedback_system_tenant_id_is_uuid).
|
|
17
|
+
const STUB_CTX: RendererContext = {
|
|
18
|
+
db: null as never,
|
|
19
|
+
registry: null as never,
|
|
20
|
+
tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Test-Helper: minimal Plugin mit fix-Response. Mehrere im Pool für
|
|
24
|
+
// Multi-Kind- + Tenant-Override-Tests.
|
|
25
|
+
function makePlugin(name: string, kinds: RendererPlugin["kinds"]): RendererPlugin {
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
kinds,
|
|
29
|
+
render: async (req: RenderRequest): Promise<RenderResponse> => {
|
|
30
|
+
// shape-by-kind, gibt name in der response damit Tests sehen welcher plugin lief
|
|
31
|
+
switch (req.kind) {
|
|
32
|
+
case "notification":
|
|
33
|
+
return { kind: "notification", html: `from:${name}` };
|
|
34
|
+
case "mail-html":
|
|
35
|
+
return { kind: "mail-html", html: `from:${name}`, text: `from:${name}` };
|
|
36
|
+
case "document-pdf":
|
|
37
|
+
return {
|
|
38
|
+
kind: "document-pdf",
|
|
39
|
+
pdfBytes: new Uint8Array([1, 2, 3]),
|
|
40
|
+
pageCount: 1,
|
|
41
|
+
sizeBytes: 3,
|
|
42
|
+
};
|
|
43
|
+
case "image-snapshot":
|
|
44
|
+
return {
|
|
45
|
+
kind: "image-snapshot",
|
|
46
|
+
imageBytes: new Uint8Array([1]),
|
|
47
|
+
format: "png",
|
|
48
|
+
width: 1,
|
|
49
|
+
height: 1,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TENANT: TenantId = "22222222-2222-4222-8222-222222222222" as TenantId;
|
|
57
|
+
|
|
58
|
+
describe("renderer-foundation :: Plugin-Selection", () => {
|
|
59
|
+
test("default-plugin für notification = 'simple'", async () => {
|
|
60
|
+
const api = createRendererFoundationApi([
|
|
61
|
+
makePlugin("simple", ["notification"]),
|
|
62
|
+
makePlugin("other", ["notification"]),
|
|
63
|
+
]);
|
|
64
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
65
|
+
expect(plugin.name).toBe("simple");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("default-plugin für mail-html = 'mail-html'", async () => {
|
|
69
|
+
const api = createRendererFoundationApi([
|
|
70
|
+
makePlugin("simple", ["notification", "mail-html"]),
|
|
71
|
+
makePlugin("mail-html", ["mail-html"]),
|
|
72
|
+
]);
|
|
73
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" });
|
|
74
|
+
expect(plugin.name).toBe("mail-html");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("default-plugin für document-pdf = 'puppeteer'", async () => {
|
|
78
|
+
const api = createRendererFoundationApi([
|
|
79
|
+
makePlugin("puppeteer", ["document-pdf", "image-snapshot"]),
|
|
80
|
+
]);
|
|
81
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" });
|
|
82
|
+
expect(plugin.name).toBe("puppeteer");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("Tenant-Override gewinnt vor Default", async () => {
|
|
86
|
+
const api = createRendererFoundationApi(
|
|
87
|
+
[makePlugin("simple", ["notification"]), makePlugin("custom-notif", ["notification"])],
|
|
88
|
+
(tid) => (tid === TENANT ? { notification: "custom-notif" } : null),
|
|
89
|
+
);
|
|
90
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
91
|
+
expect(plugin.name).toBe("custom-notif");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("Tenant-Override auf nicht-registriertes Plugin → fällt durch auf Default", async () => {
|
|
95
|
+
const api = createRendererFoundationApi([makePlugin("simple", ["notification"])], () => ({
|
|
96
|
+
notification: "ghost-plugin",
|
|
97
|
+
}));
|
|
98
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
99
|
+
expect(plugin.name).toBe("simple");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("Fallback auf erstes passendes Plugin wenn weder Tenant-Config noch Default-Name matchen", async () => {
|
|
103
|
+
const api = createRendererFoundationApi([makePlugin("nonstandard-name", ["notification"])]);
|
|
104
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
105
|
+
expect(plugin.name).toBe("nonstandard-name");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("kein Plugin für kind → RendererError", () => {
|
|
109
|
+
const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
|
|
110
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
|
|
111
|
+
RendererError,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("leerer Plugin-Pool → RendererError für jeden kind", () => {
|
|
116
|
+
const api = createRendererFoundationApi([]);
|
|
117
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "notification" })).toThrow(
|
|
118
|
+
RendererError,
|
|
119
|
+
);
|
|
120
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" })).toThrow(
|
|
121
|
+
RendererError,
|
|
122
|
+
);
|
|
123
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
|
|
124
|
+
RendererError,
|
|
125
|
+
);
|
|
126
|
+
expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "image-snapshot" })).toThrow(
|
|
127
|
+
RendererError,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("RendererError code='no_plugin_for_kind' wenn kein Plugin", () => {
|
|
132
|
+
const api = createRendererFoundationApi([]);
|
|
133
|
+
try {
|
|
134
|
+
api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
135
|
+
expect.fail("expected RendererError");
|
|
136
|
+
} catch (e) {
|
|
137
|
+
expect(e).toBeInstanceOf(RendererError);
|
|
138
|
+
expect((e as RendererError).code).toBe("no_plugin_for_kind");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("Plugin mit mehreren kinds wird für jeden passend gewählt", async () => {
|
|
143
|
+
const multi = makePlugin("multi", ["notification", "mail-html", "document-pdf"]);
|
|
144
|
+
const api = createRendererFoundationApi([multi]);
|
|
145
|
+
expect(api.createRendererForTenant({ tenantId: TENANT, kind: "notification" }).name).toBe(
|
|
146
|
+
"multi",
|
|
147
|
+
);
|
|
148
|
+
expect(api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" }).name).toBe("multi");
|
|
149
|
+
expect(api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" }).name).toBe(
|
|
150
|
+
"multi",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("Plugin mit leeren kinds wird nie ausgewählt", () => {
|
|
155
|
+
const api = createRendererFoundationApi([
|
|
156
|
+
makePlugin("empty-kinds", []),
|
|
157
|
+
makePlugin("simple", ["notification"]),
|
|
158
|
+
]);
|
|
159
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
160
|
+
expect(plugin.name).toBe("simple");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Tenant-Override mit falschem kind-Plugin → ignoriert, Fallback", async () => {
|
|
164
|
+
// Tenant config sagt "puppeteer" für notification, aber puppeteer kann
|
|
165
|
+
// nur document-pdf. Foundation muss den falsche-kind-Eintrag ignorieren.
|
|
166
|
+
const api = createRendererFoundationApi(
|
|
167
|
+
[makePlugin("simple", ["notification"]), makePlugin("puppeteer", ["document-pdf"])],
|
|
168
|
+
() => ({ notification: "puppeteer" }),
|
|
169
|
+
);
|
|
170
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
171
|
+
expect(plugin.name).toBe("simple");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("renderer-foundation :: Plugin executes render", () => {
|
|
176
|
+
test("Plugin.render returnt RenderResponse mit gleichem kind", async () => {
|
|
177
|
+
const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
|
|
178
|
+
const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
|
|
179
|
+
const response = await plugin.render(
|
|
180
|
+
{ kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
|
|
181
|
+
STUB_CTX,
|
|
182
|
+
);
|
|
183
|
+
expect(response.kind).toBe("notification");
|
|
184
|
+
if (response.kind === "notification") {
|
|
185
|
+
expect(response.html).toBe("from:simple");
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
4
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
5
|
+
import { createRendererFoundationApi } from "../api";
|
|
6
|
+
import { collectRendererPlugins, createRendererFoundationFeature } from "../feature";
|
|
7
|
+
import type { RenderRequest, RenderResponse } from "../types";
|
|
8
|
+
|
|
9
|
+
const TEST_TENANT = "11111111-1111-4111-8111-111111111111" as TenantId;
|
|
10
|
+
|
|
11
|
+
let stack: TestStack;
|
|
12
|
+
|
|
13
|
+
// Mini-Plugin via defineFeature + r.useExtension — wie ein echter
|
|
14
|
+
// renderer-plugin (renderer-simple, renderer-mail-html) sich registriert.
|
|
15
|
+
function createTestPluginFeature(name: string, kinds: ReadonlyArray<string>) {
|
|
16
|
+
return defineFeature(`renderer-${name}`, (r) => {
|
|
17
|
+
r.requires("renderer-foundation");
|
|
18
|
+
r.useExtension("renderer", name, {
|
|
19
|
+
kinds,
|
|
20
|
+
render: async (req: RenderRequest): Promise<RenderResponse> => {
|
|
21
|
+
if (req.kind === "notification") return { kind: "notification", html: `via:${name}` };
|
|
22
|
+
if (req.kind === "mail-html")
|
|
23
|
+
return { kind: "mail-html", html: `via:${name}`, text: `via:${name}` };
|
|
24
|
+
if (req.kind === "document-pdf")
|
|
25
|
+
return {
|
|
26
|
+
kind: "document-pdf",
|
|
27
|
+
pdfBytes: new Uint8Array([1]),
|
|
28
|
+
pageCount: 1,
|
|
29
|
+
sizeBytes: 1,
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
kind: "image-snapshot",
|
|
33
|
+
imageBytes: new Uint8Array([1]),
|
|
34
|
+
format: "png",
|
|
35
|
+
width: 1,
|
|
36
|
+
height: 1,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
stack = await setupTestStack({
|
|
45
|
+
features: [
|
|
46
|
+
createTemplateResolverFeature(),
|
|
47
|
+
createRendererFoundationFeature(),
|
|
48
|
+
createTestPluginFeature("simple", ["notification"]),
|
|
49
|
+
createTestPluginFeature("mail-html", ["mail-html"]),
|
|
50
|
+
createTestPluginFeature("puppeteer", ["document-pdf", "image-snapshot"]),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await stack.cleanup();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("renderer-foundation :: Plugin-Pool aus Registry", () => {
|
|
60
|
+
test("collectRendererPlugins findet alle registrierten Plugins", () => {
|
|
61
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
62
|
+
const names = plugins.map((p) => p.name).sort();
|
|
63
|
+
expect(names).toEqual(["mail-html", "puppeteer", "simple"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("jeder Plugin behält seine kinds-Deklaration", () => {
|
|
67
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
68
|
+
const byName = new Map(plugins.map((p) => [p.name, p]));
|
|
69
|
+
expect([...byName.get("simple")!.kinds]).toEqual(["notification"]);
|
|
70
|
+
expect([...byName.get("mail-html")!.kinds]).toEqual(["mail-html"]);
|
|
71
|
+
expect([...byName.get("puppeteer")!.kinds].sort()).toEqual(["document-pdf", "image-snapshot"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("API findet Default-Plugin pro kind aus echtem Pool", () => {
|
|
75
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
76
|
+
const api = createRendererFoundationApi(plugins);
|
|
77
|
+
expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" }).name).toBe(
|
|
78
|
+
"simple",
|
|
79
|
+
);
|
|
80
|
+
expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "mail-html" }).name).toBe(
|
|
81
|
+
"mail-html",
|
|
82
|
+
);
|
|
83
|
+
expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "document-pdf" }).name).toBe(
|
|
84
|
+
"puppeteer",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Plugin.render mit echtem Pool fließt end-to-end durch", async () => {
|
|
89
|
+
const plugins = collectRendererPlugins(stack.registry);
|
|
90
|
+
const api = createRendererFoundationApi(plugins);
|
|
91
|
+
const plugin = api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" });
|
|
92
|
+
const result = await plugin.render(
|
|
93
|
+
{ kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
|
|
94
|
+
{ db: stack.db, registry: stack.registry, tenantId: TEST_TENANT },
|
|
95
|
+
);
|
|
96
|
+
expect(result.kind).toBe("notification");
|
|
97
|
+
if (result.kind === "notification") {
|
|
98
|
+
expect(result.html).toBe("via:simple");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// RendererFoundationApi — Cross-Feature-Schnittstelle. Konsumenten
|
|
2
|
+
// (delivery, mail-Sender, Solon NKA-PDF-Generator) holen sich pro
|
|
3
|
+
// Render-Call den passenden Plugin via `createRendererForTenant`.
|
|
4
|
+
// Pattern symmetrisch zu ai-foundation's `createLLMProviderForTenant`.
|
|
5
|
+
|
|
6
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { DEFAULT_PLUGIN_BY_KIND, type RenderKind } from "./constants";
|
|
9
|
+
import { RendererError, type RendererPlugin } from "./types";
|
|
10
|
+
|
|
11
|
+
export type RendererFoundationApi = {
|
|
12
|
+
/**
|
|
13
|
+
* Wählt + returnt ein RendererPlugin für (tenantId × kind). Caller
|
|
14
|
+
* rufen anschließend `plugin.render(req, ctx)`.
|
|
15
|
+
*
|
|
16
|
+
* **Auswahl-Reihenfolge:**
|
|
17
|
+
* 1. Tenant-spezifische Config (config-Bundle: `rendererPluginByKind`)
|
|
18
|
+
* 2. DEFAULT_PLUGIN_BY_KIND aus constants
|
|
19
|
+
* 3. Erstes Plugin im Pool das das kind bedient
|
|
20
|
+
* 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
|
|
21
|
+
*
|
|
22
|
+
* **Caller-Pattern:** `tenantId` MUSS vom Server kommen (typisch
|
|
23
|
+
* `ctx.user.tenantId`). Plugins, die Service-Access brauchen (z.B.
|
|
24
|
+
* `renderer-mail-html` für Layout-Resolve via `template-resolver`),
|
|
25
|
+
* erhalten `RendererContext` als zweites Argument zu `render()` —
|
|
26
|
+
* matcht das Pattern von `DeliveryChannel.send(addr, msg, ctx)`.
|
|
27
|
+
* Plugins ohne Service-Deps (z.B. `renderer-simple`) ignorieren ctx.
|
|
28
|
+
*/
|
|
29
|
+
readonly createRendererForTenant: (args: {
|
|
30
|
+
readonly tenantId: TenantId;
|
|
31
|
+
readonly kind: RenderKind;
|
|
32
|
+
}) => RendererPlugin;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Plugin-Pool wird zur Boot-Zeit aus Extension-Usages aufgebaut.
|
|
36
|
+
// Aus dem Registry kommen alle registrierten Plugins. Foundation-Feature
|
|
37
|
+
// baut den Pool via `collectRendererPlugins(registry)` und steckt das
|
|
38
|
+
// Result in extraContext.
|
|
39
|
+
export function createRendererFoundationApi(
|
|
40
|
+
plugins: ReadonlyArray<RendererPlugin>,
|
|
41
|
+
tenantConfigLookup: (tenantId: TenantId) => Record<string, string> | null = () => null,
|
|
42
|
+
): RendererFoundationApi {
|
|
43
|
+
const byKind = indexByKind(plugins);
|
|
44
|
+
return {
|
|
45
|
+
createRendererForTenant: ({ tenantId, kind }) => {
|
|
46
|
+
const tenantConfig = tenantConfigLookup(tenantId) ?? {};
|
|
47
|
+
|
|
48
|
+
// 1. Tenant-Override
|
|
49
|
+
const tenantPluginName = tenantConfig[kind];
|
|
50
|
+
if (tenantPluginName) {
|
|
51
|
+
const plugin = plugins.find((p) => p.name === tenantPluginName && p.kinds.includes(kind));
|
|
52
|
+
if (plugin) return plugin;
|
|
53
|
+
// Tenant-Config zeigt auf nicht-registriertes Plugin — fällt durch zu Default
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Default-Plugin
|
|
57
|
+
const defaultName = DEFAULT_PLUGIN_BY_KIND[kind];
|
|
58
|
+
if (defaultName) {
|
|
59
|
+
const plugin = plugins.find((p) => p.name === defaultName && p.kinds.includes(kind));
|
|
60
|
+
if (plugin) return plugin;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Erstes Plugin im Pool das das kind bedient
|
|
64
|
+
const first = byKind.get(kind)?.[0];
|
|
65
|
+
if (first) return first;
|
|
66
|
+
|
|
67
|
+
// 4. Kein Plugin → Error
|
|
68
|
+
throw new RendererError(
|
|
69
|
+
`[renderer-foundation] no plugin registered for kind="${kind}". Mount at least one plugin (renderer-simple, renderer-mail-html, renderer-puppeteer-client) for this kind.`,
|
|
70
|
+
"no_plugin_for_kind",
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function indexByKind(plugins: ReadonlyArray<RendererPlugin>): Map<RenderKind, RendererPlugin[]> {
|
|
77
|
+
const map = new Map<RenderKind, RendererPlugin[]>();
|
|
78
|
+
for (const plugin of plugins) {
|
|
79
|
+
for (const kind of plugin.kinds) {
|
|
80
|
+
const list = map.get(kind) ?? [];
|
|
81
|
+
list.push(plugin);
|
|
82
|
+
map.set(kind, list);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Single point of truth für "dieser Handler braucht renderer-foundation".
|
|
89
|
+
// Pattern symmetrisch zu requireTemplateResolver + requireTextContent.
|
|
90
|
+
export function requireRendererFoundation(
|
|
91
|
+
ctx: { readonly rendererFoundation?: RendererFoundationApi } | object,
|
|
92
|
+
callerName: string,
|
|
93
|
+
): RendererFoundationApi {
|
|
94
|
+
// @cast-boundary engine-bridge — rendererFoundation kommt per extraContext
|
|
95
|
+
// aus App-Bootstrap.
|
|
96
|
+
const api = (ctx as { rendererFoundation?: RendererFoundationApi }).rendererFoundation;
|
|
97
|
+
if (!api) {
|
|
98
|
+
throw new InternalError({
|
|
99
|
+
message:
|
|
100
|
+
`[${callerName}] ctx.rendererFoundation missing — App-Bootstrap muss ` +
|
|
101
|
+
`extraContext: { rendererFoundation: createRendererFoundationApi(plugins) } setzen ` +
|
|
102
|
+
`(siehe renderer-foundation/README.md).`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return api;
|
|
106
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Re-export aus template-resolver — gleiche RenderKind-Domäne. Beide
|
|
2
|
+
// Bundles teilen sich die Enum, damit Plugins (renderer-mail-html etc.)
|
|
3
|
+
// nicht zwei Quellen importieren müssen. Cross-feature-Imports gehen
|
|
4
|
+
// über das Barrel (../template-resolver), nicht via deep-import.
|
|
5
|
+
import type { RenderKind as RenderKindLocal } from "../template-resolver";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
CONTENT_FORMATS,
|
|
9
|
+
type ContentFormat,
|
|
10
|
+
RENDER_KINDS,
|
|
11
|
+
type RenderKind,
|
|
12
|
+
} from "../template-resolver";
|
|
13
|
+
|
|
14
|
+
// Standard-Default-Plugin pro Kind, wenn Tenant keine explizite Config
|
|
15
|
+
// gesetzt hat. App-Bootstrap kann das via TenantConfigKey überschreiben.
|
|
16
|
+
export const DEFAULT_PLUGIN_BY_KIND: Readonly<Record<RenderKindLocal, string>> = {
|
|
17
|
+
notification: "simple",
|
|
18
|
+
"mail-html": "mail-html",
|
|
19
|
+
"document-pdf": "puppeteer",
|
|
20
|
+
"image-snapshot": "puppeteer",
|
|
21
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { RendererPlugin } from "./types";
|
|
3
|
+
|
|
4
|
+
// renderer-foundation — Plugin-Foundation für Renderer (Notification,
|
|
5
|
+
// HTML-Mail, PDF, Image). Plan-Doc:
|
|
6
|
+
// kumiko-platform/docs/plans/features/renderer-foundation.md
|
|
7
|
+
//
|
|
8
|
+
// Pattern symmetrisch zu ai-foundation: Foundation definiert den
|
|
9
|
+
// Extension-Point `renderer`, Plugins (renderer-simple, renderer-mail-html,
|
|
10
|
+
// renderer-puppeteer-client) registrieren sich via `r.useExtension`.
|
|
11
|
+
// Konsumenten holen sich Plugin runtime via createRendererForTenant.
|
|
12
|
+
export function createRendererFoundationFeature() {
|
|
13
|
+
return defineFeature("renderer-foundation", (r) => {
|
|
14
|
+
r.requires("template-resolver");
|
|
15
|
+
|
|
16
|
+
r.extendsRegistrar("renderer", {
|
|
17
|
+
onRegister: () => {
|
|
18
|
+
// Plugin-Konformitäts-Check könnte hier: shape-validation der
|
|
19
|
+
// options (kinds, render-Funktion present). Aktuell kein
|
|
20
|
+
// shape-check — Caller's TypeScript-Type-Sicherheit greift.
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return {};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Plugin-Pool-Aufbau zur Boot-Zeit. App-Bootstrap ruft das nach
|
|
29
|
+
// Feature-Registration auf, baut den Pool, gibt ihn via extraContext
|
|
30
|
+
// an die Handlers weiter.
|
|
31
|
+
//
|
|
32
|
+
// Symmetrisch zu collectChannels / collectRenderers in delivery-service.
|
|
33
|
+
export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
|
|
34
|
+
const usages = registry.getExtensionUsages("renderer");
|
|
35
|
+
return usages.map((usage) => {
|
|
36
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
37
|
+
const opts = usage.options as {
|
|
38
|
+
kinds: RendererPlugin["kinds"];
|
|
39
|
+
render: RendererPlugin["render"];
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
name: usage.entityName,
|
|
43
|
+
kinds: opts.kinds,
|
|
44
|
+
render: opts.render,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createRendererFoundationApi,
|
|
3
|
+
type RendererFoundationApi,
|
|
4
|
+
requireRendererFoundation,
|
|
5
|
+
} from "./api";
|
|
6
|
+
export {
|
|
7
|
+
CONTENT_FORMATS,
|
|
8
|
+
type ContentFormat,
|
|
9
|
+
DEFAULT_PLUGIN_BY_KIND,
|
|
10
|
+
RENDER_KINDS,
|
|
11
|
+
type RenderKind,
|
|
12
|
+
} from "./constants";
|
|
13
|
+
export { collectRendererPlugins, createRendererFoundationFeature } from "./feature";
|
|
14
|
+
export {
|
|
15
|
+
type DocumentPayload,
|
|
16
|
+
type ImageOptions,
|
|
17
|
+
type MailHtmlPayload,
|
|
18
|
+
type NotificationPayload,
|
|
19
|
+
type PdfOptions,
|
|
20
|
+
type RendererContext,
|
|
21
|
+
RendererError,
|
|
22
|
+
type RendererPlugin,
|
|
23
|
+
type RenderRequest,
|
|
24
|
+
type RenderResponse,
|
|
25
|
+
} from "./types";
|