@cosmicdrift/kumiko-bundled-features 0.104.0 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.104.0",
3
+ "version": "0.105.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -92,11 +92,11 @@
92
92
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
93
93
  },
94
94
  "dependencies": {
95
- "@cosmicdrift/kumiko-dispatcher-live": "0.104.0",
96
- "@cosmicdrift/kumiko-framework": "0.104.0",
97
- "@cosmicdrift/kumiko-headless": "0.104.0",
98
- "@cosmicdrift/kumiko-renderer": "0.104.0",
99
- "@cosmicdrift/kumiko-renderer-web": "0.104.0",
95
+ "@cosmicdrift/kumiko-dispatcher-live": "0.105.0",
96
+ "@cosmicdrift/kumiko-framework": "0.105.0",
97
+ "@cosmicdrift/kumiko-headless": "0.105.0",
98
+ "@cosmicdrift/kumiko-renderer": "0.105.0",
99
+ "@cosmicdrift/kumiko-renderer-web": "0.105.0",
100
100
  "@mollie/api-client": "^4.5.0",
101
101
  "@node-rs/argon2": "^2.0.2",
102
102
  "@types/nodemailer": "^8.0.0",
@@ -1,16 +1,26 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { RendererError } from "../../renderer-foundation";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type RendererContext, RendererError } from "../../renderer-foundation";
3
4
  import { adaptToFoundation } from "../feature";
4
5
 
6
+ const STUB_CTX: RendererContext = {
7
+ db: null as never,
8
+ registry: null as never,
9
+ tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
10
+ };
11
+
5
12
  describe("renderer-simple :: adaptToFoundation", () => {
6
13
  test("kind='notification' rendert via simpleRenderer und gibt RenderResponse zurück", async () => {
7
- const res = await adaptToFoundation({
8
- kind: "notification",
9
- payload: {
10
- template: "welcome",
11
- variables: { title: "Welcome!", body: "Hello there" },
14
+ const res = await adaptToFoundation(
15
+ {
16
+ kind: "notification",
17
+ payload: {
18
+ template: "welcome",
19
+ variables: { title: "Welcome!", body: "Hello there" },
20
+ },
12
21
  },
13
- });
22
+ STUB_CTX,
23
+ );
14
24
  expect(res.kind).toBe("notification");
15
25
  if (res.kind === "notification") {
16
26
  // simpleRenderer baut HTML mit title als header + body als section
@@ -21,26 +31,35 @@ describe("renderer-simple :: adaptToFoundation", () => {
21
31
  });
22
32
 
23
33
  test("leere variables → leerer body, kein crash", async () => {
24
- const res = await adaptToFoundation({
25
- kind: "notification",
26
- payload: { template: "", variables: {} },
27
- });
34
+ const res = await adaptToFoundation(
35
+ {
36
+ kind: "notification",
37
+ payload: { template: "", variables: {} },
38
+ },
39
+ STUB_CTX,
40
+ );
28
41
  expect(res.kind).toBe("notification");
29
42
  });
30
43
 
31
44
  test("non-notification kind → RendererError mit code 'invalid_payload'", async () => {
32
45
  await expect(
33
- adaptToFoundation({
34
- kind: "mail-html",
35
- payload: { content: "test", contentFormat: "markdown" },
36
- }),
46
+ adaptToFoundation(
47
+ {
48
+ kind: "mail-html",
49
+ payload: { content: "test", contentFormat: "markdown" },
50
+ },
51
+ STUB_CTX,
52
+ ),
37
53
  ).rejects.toThrow(RendererError);
38
54
 
39
55
  try {
40
- await adaptToFoundation({
41
- kind: "document-pdf",
42
- payload: { content: "test", contentFormat: "markdown" },
43
- });
56
+ await adaptToFoundation(
57
+ {
58
+ kind: "document-pdf",
59
+ payload: { content: "test", contentFormat: "markdown" },
60
+ },
61
+ STUB_CTX,
62
+ );
44
63
  throw new Error("expected RendererError");
45
64
  } catch (e) {
46
65
  expect(e).toBeInstanceOf(RendererError);
@@ -0,0 +1,102 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
+ import {
6
+ setupTestStack,
7
+ type TestStack,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import type { RendererContext } from "../../renderer-foundation";
11
+ import { SYSTEM_TENANT_ID } from "../../template-resolver/constants";
12
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
13
+ import { templateResourceEntity, templateResourcesTable } from "../../template-resolver/table";
14
+ import { adaptToFoundation } from "../feature";
15
+
16
+ let stack: TestStack;
17
+ let db: DbConnection;
18
+
19
+ const TENANT_ID = "11111111-1111-4111-8111-111111111111" as TenantId;
20
+
21
+ const templateResolverFeature = createTemplateResolverFeature();
22
+
23
+ beforeAll(async () => {
24
+ stack = await setupTestStack({ features: [templateResolverFeature] });
25
+ db = stack.db;
26
+ await unsafeCreateEntityTable(db, templateResourceEntity);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await stack.cleanup();
31
+ });
32
+
33
+ function rendererCtx(): RendererContext {
34
+ return { db, registry: stack.registry, tenantId: TENANT_ID };
35
+ }
36
+
37
+ async function seedPlainNotificationTemplate(content: string): Promise<void> {
38
+ await insertOne(db, templateResourcesTable, {
39
+ tenantId: SYSTEM_TENANT_ID,
40
+ slug: "welcome-mail",
41
+ kind: "notification",
42
+ locale: "de",
43
+ scope: "system",
44
+ status: "active",
45
+ content,
46
+ contentFormat: "plain",
47
+ variableSchema: JSON.stringify({}),
48
+ linkedResources: JSON.stringify({}),
49
+ parentTemplateId: null,
50
+ createdBy: "test",
51
+ updatedBy: "test",
52
+ });
53
+ }
54
+
55
+ describe("renderer-simple :: template-resolver integration", () => {
56
+ test("resolves slug via template-resolver and merges runtime variables", async () => {
57
+ await seedPlainNotificationTemplate(
58
+ JSON.stringify({
59
+ header: "Template header",
60
+ sections: [{ text: "Template body" }],
61
+ }),
62
+ );
63
+
64
+ const res = await adaptToFoundation(
65
+ {
66
+ kind: "notification",
67
+ payload: {
68
+ template: "welcome-mail",
69
+ locale: "de",
70
+ variables: { header: "Runtime override" },
71
+ },
72
+ },
73
+ rendererCtx(),
74
+ );
75
+
76
+ expect(res.kind).toBe("notification");
77
+ if (res.kind === "notification") {
78
+ expect(res.html).toContain("Runtime override");
79
+ expect(res.html).toContain("Template body");
80
+ expect(res.html).not.toContain("Template header");
81
+ }
82
+ });
83
+
84
+ test("falls back to variables when slug is unknown in template-resolver", async () => {
85
+ const res = await adaptToFoundation(
86
+ {
87
+ kind: "notification",
88
+ payload: {
89
+ template: "password-reset",
90
+ variables: { title: "Reset", body: "Click the link" },
91
+ },
92
+ },
93
+ rendererCtx(),
94
+ );
95
+
96
+ expect(res.kind).toBe("notification");
97
+ if (res.kind === "notification") {
98
+ expect(res.html).toContain("Reset");
99
+ expect(res.html).toContain("Click the link");
100
+ }
101
+ });
102
+ });
@@ -1,5 +1,11 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { RendererError, type RenderRequest, type RenderResponse } from "../renderer-foundation";
2
+ import {
3
+ type RendererContext,
4
+ RendererError,
5
+ type RenderRequest,
6
+ type RenderResponse,
7
+ } from "../renderer-foundation";
8
+ import { resolveNotificationVariables } from "./resolve-variables";
3
9
  import { simpleRenderer } from "./simple-renderer";
4
10
 
5
11
  // Adapter: simpleRenderer.render hat `Promise<string>`-Signatur (Legacy
@@ -9,7 +15,10 @@ import { simpleRenderer } from "./simple-renderer";
9
15
  // Inline-CSS) und packt sie in den RendererPlugin-Contract.
10
16
  //
11
17
  // Exported damit der Adapter-Pfad direkt testbar ist (unit-test).
12
- export async function adaptToFoundation(req: RenderRequest): Promise<RenderResponse> {
18
+ export async function adaptToFoundation(
19
+ req: RenderRequest,
20
+ ctx: RendererContext,
21
+ ): Promise<RenderResponse> {
13
22
  if (req.kind !== "notification") {
14
23
  // Defensiver Guard — Foundation wählt Plugins nur für matching kinds,
15
24
  // dieser Pfad sollte unter normalen Umständen nie erreicht werden.
@@ -18,9 +27,10 @@ export async function adaptToFoundation(req: RenderRequest): Promise<RenderRespo
18
27
  "invalid_payload",
19
28
  );
20
29
  }
30
+ const variables = await resolveNotificationVariables(req, ctx);
21
31
  const html = await simpleRenderer.render({
22
32
  template: req.payload.template ?? "",
23
- variables: req.payload.variables ?? {},
33
+ variables,
24
34
  });
25
35
  return { kind: "notification", html };
26
36
  }
@@ -36,6 +46,7 @@ export function createRendererSimpleFeature(): FeatureDefinition {
36
46
  recommended: false,
37
47
  });
38
48
  r.requires("renderer-foundation");
49
+ r.optionalRequires("template-resolver");
39
50
 
40
51
  r.useExtension("renderer", "simple", {
41
52
  kinds: ["notification"] as const,
@@ -0,0 +1,55 @@
1
+ import type { RendererContext, RenderRequest } from "../renderer-foundation";
2
+ import {
3
+ createTemplateResolverApi,
4
+ FALLBACK_LOCALE,
5
+ TemplateNotFoundError,
6
+ } from "../template-resolver";
7
+
8
+ type NotificationRequest = Extract<RenderRequest, { kind: "notification" }>;
9
+
10
+ /** Merge template-resolver content (plain JSON EmailTemplateData) with runtime variables. */
11
+ export async function resolveNotificationVariables(
12
+ req: NotificationRequest,
13
+ ctx: RendererContext,
14
+ ): Promise<Readonly<Record<string, unknown>>> {
15
+ const variables = req.payload.variables ?? {};
16
+ const slug = req.payload.template?.trim();
17
+ if (!slug || req.payload.content || !ctx.db) {
18
+ return variables;
19
+ }
20
+
21
+ try {
22
+ const api = createTemplateResolverApi(ctx.db);
23
+ const locale = req.payload.locale ?? FALLBACK_LOCALE;
24
+ const resolved = await api.resolveTemplate({
25
+ tenantId: ctx.tenantId,
26
+ slug,
27
+ kind: "notification",
28
+ locale,
29
+ });
30
+ if (resolved.contentFormat === "plain") {
31
+ const base = parsePlainTemplateContent(resolved.content);
32
+ return { ...base, ...variables };
33
+ }
34
+ return { ...variables, body: resolved.content };
35
+ } catch (err) {
36
+ if (err instanceof TemplateNotFoundError) {
37
+ return variables;
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ function parsePlainTemplateContent(content: string): Record<string, unknown> {
44
+ const trimmed = content.trim();
45
+ if (!trimmed) return {};
46
+ try {
47
+ const parsed: unknown = JSON.parse(trimmed);
48
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
49
+ return parsed as Record<string, unknown>;
50
+ }
51
+ } catch {
52
+ // ponytail: non-JSON plain content becomes a single body section downstream
53
+ }
54
+ return { body: content };
55
+ }