@cosmicdrift/kumiko-bundled-features 0.104.0 → 0.105.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/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.1",
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>",
@@ -82,6 +82,7 @@
82
82
  "./text-content/seeding": "./src/text-content/seeding.ts",
83
83
  "./text-content/web": "./src/text-content/web/index.ts",
84
84
  "./template-resolver": "./src/template-resolver/index.ts",
85
+ "./template-resolver/seeding": "./src/template-resolver/seeding.ts",
85
86
  "./template-resolver/testing": "./src/template-resolver/testing.ts",
86
87
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
87
88
  "./legal-pages": "./src/legal-pages/index.ts",
@@ -92,11 +93,11 @@
92
93
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
93
94
  },
94
95
  "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",
96
+ "@cosmicdrift/kumiko-dispatcher-live": "0.105.1",
97
+ "@cosmicdrift/kumiko-framework": "0.105.1",
98
+ "@cosmicdrift/kumiko-headless": "0.105.1",
99
+ "@cosmicdrift/kumiko-renderer": "0.105.1",
100
+ "@cosmicdrift/kumiko-renderer-web": "0.105.1",
100
101
  "@mollie/api-client": "^4.5.0",
101
102
  "@node-rs/argon2": "^2.0.2",
102
103
  "@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
+ }
@@ -55,6 +55,23 @@ async function someHandler(ctx) {
55
55
 
56
56
  Wenn nichts gefunden → `TemplateNotFoundError`.
57
57
 
58
+ ## Boot-Seeding (System-Templates)
59
+
60
+ ```typescript
61
+ import { seedSystemTemplate } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
62
+
63
+ // runProdApp seeds — idempotent, default skip if slug exists
64
+ await seedSystemTemplate(db, {
65
+ slug: "welcome-email",
66
+ kind: "notification",
67
+ locale: "de",
68
+ content: JSON.stringify({ header: "Willkommen", sections: [{ text: "…" }] }),
69
+ contentFormat: "plain",
70
+ });
71
+ ```
72
+
73
+ `ifExists: "update"` für autoritative Re-Seeds (z.B. nach Template-Content-Release).
74
+
58
75
  ## Admin-Workflows (Write-Handlers + Queries)
59
76
 
60
77
  | Handler | QN | Wer | Was |
@@ -66,7 +83,7 @@ Wenn nichts gefunden → `TemplateNotFoundError`.
66
83
  | `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (eigener Tenant + system-templates sichtbar) | Raw-Lookup für Edit-UI |
67
84
  | `TemplateResolverQueries.list` | `template-resolver:query:list` | gleich | Filter nach kind/locale/status, optional includeSystem |
68
85
 
69
- **SystemAdmin-Cross-Tenant für publish/archive/findById:** aktuell nicht implementiert. `ctx.db` ist tenant-scoped (createTenantDb in dispatcher), SystemAdmin sieht ohne explicit `tenantIdOverride` keine fremden Tenants. Wenn Admin-UI das fordert: Schema-Erweiterung in einer M2-Iteration.
86
+ **SystemAdmin-Cross-Tenant für publish/archive/findById:** deferred (Task 8) `upsertTenant` hat `tenantIdOverride` bereits; publish/archive brauchen das erst wenn ein Sysadmin-Panel fremde Tenant-Templates kuratiert. `ctx.db` ist tenant-scoped, fremde IDs NotFound ohne Override.
70
87
 
71
88
  ## Status-Lifecycle
72
89
 
@@ -109,3 +126,5 @@ The harness checks `TemplateNotFoundError` propagation, locale-fallback, and (wh
109
126
  - Resource-URL-Substitution (signed-URL vs. data-URI) — Caller-Verantwortung je nach kind
110
127
  - Visual Template-Editor — `designer`-Bundle (geplant)
111
128
  - A/B-Testing — eigenes Bundle wenn Bedarf real
129
+
130
+
@@ -0,0 +1,107 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ setupTestStack,
6
+ type TestStack,
7
+ testTenantId,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { createTemplateResolverApi } from "../api";
11
+ import { SYSTEM_TENANT_ID } from "../constants";
12
+ import { createTemplateResolverFeature } from "../feature";
13
+ import { seedSystemTemplate } from "../seeding";
14
+ import { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "../table";
15
+
16
+ let stack: TestStack;
17
+
18
+ const TENANT_ID = testTenantId(710) as TenantId;
19
+
20
+ const feature = createTemplateResolverFeature();
21
+
22
+ beforeAll(async () => {
23
+ stack = await setupTestStack({ features: [feature] });
24
+ await unsafeCreateEntityTable(stack.db, templateResourceEntity);
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await stack.cleanup();
29
+ });
30
+
31
+ describe("seedSystemTemplate", () => {
32
+ test("legt System-Template an, resolveTemplate findet es für beliebigen Tenant", async () => {
33
+ const slug = `welcome-${crypto.randomUUID()}`;
34
+ await seedSystemTemplate(stack.db, {
35
+ slug,
36
+ kind: "notification",
37
+ locale: "de",
38
+ content: JSON.stringify({ header: "Willkommen", sections: [{ text: "Hallo" }] }),
39
+ contentFormat: "plain",
40
+ });
41
+
42
+ const api = createTemplateResolverApi(stack.db);
43
+ const resolved = await api.resolveTemplate({
44
+ tenantId: TENANT_ID,
45
+ slug,
46
+ kind: "notification",
47
+ locale: "de",
48
+ });
49
+ expect(resolved.content).toContain("Willkommen");
50
+ expect(resolved.scope).toBe("system");
51
+ });
52
+
53
+ test('ifExists="update" überschreibt content', async () => {
54
+ const slug = `incident-${crypto.randomUUID()}`;
55
+ await seedSystemTemplate(stack.db, {
56
+ slug,
57
+ kind: "notification",
58
+ locale: "en",
59
+ content: "v1",
60
+ contentFormat: "plain",
61
+ });
62
+ await seedSystemTemplate(stack.db, {
63
+ slug,
64
+ kind: "notification",
65
+ locale: "en",
66
+ content: "v2",
67
+ contentFormat: "plain",
68
+ ifExists: "update",
69
+ });
70
+
71
+ const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
72
+ tenantId: SYSTEM_TENANT_ID,
73
+ slug,
74
+ kind: "notification",
75
+ locale: "en",
76
+ });
77
+ expect(row?.content).toBe("v2");
78
+ expect(row?.version).toBe(2);
79
+ });
80
+
81
+ test("default skip: zweiter Boot-Call ohne update ändert nichts", async () => {
82
+ const slug = `status-${crypto.randomUUID()}`;
83
+ await seedSystemTemplate(stack.db, {
84
+ slug,
85
+ kind: "notification",
86
+ locale: "de",
87
+ content: "original",
88
+ contentFormat: "plain",
89
+ });
90
+ await seedSystemTemplate(stack.db, {
91
+ slug,
92
+ kind: "notification",
93
+ locale: "de",
94
+ content: "would-overwrite",
95
+ contentFormat: "plain",
96
+ });
97
+
98
+ const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
99
+ tenantId: SYSTEM_TENANT_ID,
100
+ slug,
101
+ kind: "notification",
102
+ locale: "de",
103
+ });
104
+ expect(row?.content).toBe("original");
105
+ expect(row?.version).toBe(1);
106
+ });
107
+ });
@@ -0,0 +1,96 @@
1
+ // Boot-/Seed-Helper für App-Authors. Schreibt System-Templates (tenantId=
2
+ // SYSTEM_TENANT_ID, scope=system, status=active) idempotent in die DB —
3
+ // gleicher Projection-Pfad wie upsertSystem-Handler, ohne Access-Check.
4
+ //
5
+ // **Wann nutzen?** runProdApp-seeds / TenantCreated-Hooks die Welcome-,
6
+ // Incident- oder Feature-Mail-Slugs installieren, damit renderer-simple
7
+ // (oder mail-html) sie per template-resolver auflösen kann.
8
+ //
9
+ // Default ifExists="skip". `createSystemUser(SYSTEM_TENANT_ID)` als Actor —
10
+ // bewusst nicht TestUsers (Prod-Seeds ≠ Test-Utilities).
11
+
12
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
+ import {
15
+ createSystemUser,
16
+ type SessionUser,
17
+ SYSTEM_TENANT_ID,
18
+ type TenantId,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
21
+ import type { ContentFormat, RenderKind } from "./constants";
22
+ import { executor } from "./handlers/shared";
23
+ import { type TemplateResourceRow, templateResourcesTable } from "./table";
24
+
25
+ export type SeedSystemTemplateOptions = {
26
+ readonly slug: string;
27
+ readonly kind: RenderKind;
28
+ readonly locale: string;
29
+ readonly content: string;
30
+ readonly contentFormat: ContentFormat;
31
+ readonly variableSchema?: Record<string, unknown>;
32
+ readonly linkedResources?: Record<string, string>;
33
+ readonly parentTemplateId?: string | null;
34
+ readonly by?: SessionUser;
35
+ readonly ifExists?: SeedIfExists;
36
+ };
37
+
38
+ export async function seedSystemTemplate(
39
+ db: DbConnection,
40
+ opts: SeedSystemTemplateOptions,
41
+ ): Promise<{ id: string }> {
42
+ const tenantId = SYSTEM_TENANT_ID as TenantId;
43
+ const by = opts.by ?? createSystemUser(tenantId);
44
+ const tdb = createTenantDb(db, tenantId, "system");
45
+
46
+ const existing = (await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
47
+ tenantId,
48
+ slug: opts.slug,
49
+ kind: opts.kind,
50
+ locale: opts.locale,
51
+ })) as { id: string; version: number } | null;
52
+
53
+ const variableSchema = JSON.stringify(opts.variableSchema ?? {});
54
+ const linkedResources = JSON.stringify(opts.linkedResources ?? {});
55
+ const parentTemplateId = opts.parentTemplateId ?? null;
56
+
57
+ const rowFields = {
58
+ slug: opts.slug,
59
+ kind: opts.kind,
60
+ locale: opts.locale,
61
+ content: opts.content,
62
+ contentFormat: opts.contentFormat,
63
+ variableSchema,
64
+ linkedResources,
65
+ scope: "system" as const,
66
+ parentTemplateId,
67
+ status: "active" as const,
68
+ };
69
+
70
+ return runEventStoreSeed({
71
+ existing,
72
+ ifExists: opts.ifExists,
73
+ create: async () => {
74
+ const result = await executor.create({ ...rowFields, tenantId }, by, tdb);
75
+ if (!result.isSuccess) {
76
+ throw new Error(`seedSystemTemplate create failed: ${JSON.stringify(result)}`);
77
+ }
78
+ const data = result.data as Partial<TemplateResourceRow>;
79
+ if (data.id === undefined) {
80
+ throw new Error("seedSystemTemplate: executor.create did not return an id");
81
+ }
82
+ return { id: String(data.id) };
83
+ },
84
+ update: async (row) => {
85
+ const result = await executor.update(
86
+ { id: row.id, version: row.version, changes: rowFields },
87
+ by,
88
+ tdb,
89
+ );
90
+ if (!result.isSuccess) {
91
+ throw new Error(`seedSystemTemplate update failed: ${JSON.stringify(result)}`);
92
+ }
93
+ return { id: String(row.id) };
94
+ },
95
+ });
96
+ }