@cosmicdrift/kumiko-bundled-features 0.100.0 → 0.102.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.
Files changed (32) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/auth-mailer.ts +28 -12
  3. package/src/auth-email-password/index.ts +3 -0
  4. package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
  5. package/src/channel-email/email-channel.ts +32 -20
  6. package/src/channel-email/feature.ts +2 -0
  7. package/src/channel-email/index.ts +6 -1
  8. package/src/channel-email/smtp-transport.ts +37 -0
  9. package/src/channel-in-app/feature.ts +1 -0
  10. package/src/channel-in-app/in-app-channel.ts +1 -0
  11. package/src/channel-push/feature.ts +1 -0
  12. package/src/channel-push/push-channel.ts +1 -0
  13. package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
  14. package/src/delivery/attempt-log.ts +54 -0
  15. package/src/delivery/channel-context.ts +17 -0
  16. package/src/delivery/constants.ts +35 -1
  17. package/src/delivery/delivery-service.ts +118 -47
  18. package/src/delivery/events.ts +7 -1
  19. package/src/delivery/feature.ts +38 -14
  20. package/src/delivery/index.ts +3 -0
  21. package/src/delivery/jobs.ts +153 -0
  22. package/src/delivery/tables.ts +5 -1
  23. package/src/delivery/types.ts +25 -2
  24. package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
  25. package/src/presets/dsgvo-self-service.ts +30 -0
  26. package/src/presets/index.ts +1 -0
  27. package/src/secrets/feature.ts +3 -1
  28. package/src/secrets/index.ts +1 -0
  29. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
  30. package/src/text-content/seeding.ts +35 -1
  31. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
  32. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.100.0",
3
+ "version": "0.102.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>",
@@ -73,6 +73,7 @@
73
73
  "./renderer-simple": "./src/renderer-simple/index.ts",
74
74
  "./files-provider-s3": "./src/files-provider-s3/index.ts",
75
75
  "./rate-limiting": "./src/rate-limiting/index.ts",
76
+ "./presets": "./src/presets/index.ts",
76
77
  "./secrets": "./src/secrets/index.ts",
77
78
  "./sessions": "./src/sessions/index.ts",
78
79
  "./sessions/testing": "./src/sessions/testing.ts",
@@ -91,11 +92,11 @@
91
92
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
92
93
  },
93
94
  "dependencies": {
94
- "@cosmicdrift/kumiko-dispatcher-live": "0.100.0",
95
- "@cosmicdrift/kumiko-framework": "0.100.0",
96
- "@cosmicdrift/kumiko-headless": "0.100.0",
97
- "@cosmicdrift/kumiko-renderer": "0.100.0",
98
- "@cosmicdrift/kumiko-renderer-web": "0.100.0",
95
+ "@cosmicdrift/kumiko-dispatcher-live": "0.102.0",
96
+ "@cosmicdrift/kumiko-framework": "0.102.0",
97
+ "@cosmicdrift/kumiko-headless": "0.102.0",
98
+ "@cosmicdrift/kumiko-renderer": "0.102.0",
99
+ "@cosmicdrift/kumiko-renderer-web": "0.102.0",
99
100
  "@mollie/api-client": "^4.5.0",
100
101
  "@node-rs/argon2": "^2.0.2",
101
102
  "@types/nodemailer": "^8.0.0",
@@ -56,20 +56,35 @@ export type AuthMailerConfig = {
56
56
  };
57
57
  };
58
58
 
59
+ /** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
60
+ export type AuthPaths = {
61
+ readonly resetPassword: string;
62
+ readonly verifyEmail: string;
63
+ readonly signupComplete: string;
64
+ readonly inviteAccept: string;
65
+ };
66
+
67
+ /** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
68
+ * nur die Ausnahme via `makeAuthPaths({ ... })`. */
69
+ export const DEFAULT_AUTH_PATHS: AuthPaths = {
70
+ resetPassword: "/reset-password",
71
+ verifyEmail: "/verify-email",
72
+ signupComplete: "/signup/complete",
73
+ inviteAccept: "/invite/accept",
74
+ };
75
+
76
+ export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
77
+ return { ...DEFAULT_AUTH_PATHS, ...overrides };
78
+ }
79
+
59
80
  export type CreateAuthMailerConfigArgs = {
60
81
  readonly mailSender: EmailTransport;
61
82
  readonly hmacSecret: string;
62
83
  /** Basis-URL der App inkl. Schema (z.B. "https://admin.example.com").
63
84
  * Die factory hängt paths.resetPassword etc. an. */
64
85
  readonly baseUrl: string;
65
- /** Pfad-Konstanten für die Auth-Seiten jede App hat ihre eigenen
66
- * in `./auth-paths.ts`. */
67
- readonly paths: {
68
- readonly resetPassword: string;
69
- readonly verifyEmail: string;
70
- readonly signupComplete: string;
71
- readonly inviteAccept: string;
72
- };
86
+ /** Pfad-Konstanten für die Auth-Seiten. Default: DEFAULT_AUTH_PATHS. */
87
+ readonly paths?: AuthPaths;
73
88
  /** App-Name für Mail-Subject + Body. Default "Account". */
74
89
  readonly appName?: string;
75
90
  /** Locale für die Mail-Templates. Default "de". */
@@ -91,10 +106,11 @@ export type CreateAuthMailerConfigArgs = {
91
106
  export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMailerConfig {
92
107
  const appName = args.appName ?? "Account";
93
108
  const locale = args.locale ?? "de";
109
+ const paths = args.paths ?? DEFAULT_AUTH_PATHS;
94
110
  return {
95
111
  passwordReset: {
96
112
  hmacSecret: args.hmacSecret,
97
- appResetUrl: `${args.baseUrl}${args.paths.resetPassword}`,
113
+ appResetUrl: `${args.baseUrl}${paths.resetPassword}`,
98
114
  sendResetEmail: async ({ email, resetUrl, expiresAt }) => {
99
115
  await args.mailSender.send({
100
116
  to: email,
@@ -107,7 +123,7 @@ export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMa
107
123
  ...(args.emailVerificationMode !== undefined && {
108
124
  mode: args.emailVerificationMode,
109
125
  }),
110
- appVerifyUrl: `${args.baseUrl}${args.paths.verifyEmail}`,
126
+ appVerifyUrl: `${args.baseUrl}${paths.verifyEmail}`,
111
127
  sendVerificationEmail: async ({ email, verificationUrl, expiresAt }) => {
112
128
  await args.mailSender.send({
113
129
  to: email,
@@ -116,7 +132,7 @@ export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMa
116
132
  },
117
133
  },
118
134
  signup: {
119
- appActivationUrl: `${args.baseUrl}${args.paths.signupComplete}`,
135
+ appActivationUrl: `${args.baseUrl}${paths.signupComplete}`,
120
136
  sendActivationEmail: async ({ email, activationUrl, expiresAt }) => {
121
137
  await args.mailSender.send({
122
138
  to: email,
@@ -125,7 +141,7 @@ export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMa
125
141
  },
126
142
  },
127
143
  invite: {
128
- appAcceptUrl: `${args.baseUrl}${args.paths.inviteAccept}`,
144
+ appAcceptUrl: `${args.baseUrl}${paths.inviteAccept}`,
129
145
  sendInviteEmail: async ({ email, inviteUrl, expiresAt, role }) => {
130
146
  await args.mailSender.send({
131
147
  to: email,
@@ -4,8 +4,11 @@
4
4
  // und solon (jede App hatte identische send*Email-Wrapper kopiert).
5
5
  export {
6
6
  type AuthMailerConfig,
7
+ type AuthPaths,
7
8
  type CreateAuthMailerConfigArgs,
8
9
  createAuthMailerConfig,
10
+ DEFAULT_AUTH_PATHS,
11
+ makeAuthPaths,
9
12
  } from "./auth-mailer";
10
13
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
11
14
  // Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createSmtpTransportFromEnv } from "../smtp-transport";
3
+
4
+ describe("createSmtpTransportFromEnv", () => {
5
+ test("no SMTP_HOST → null (no-mail, not crash)", () => {
6
+ expect(createSmtpTransportFromEnv({}, { fallbackFrom: "x@y.z" })).toBeNull();
7
+ expect(
8
+ createSmtpTransportFromEnv(
9
+ { SMTP_USER: "u", SMTP_PASS: "p", SMTP_FROM: "a@b.c" },
10
+ { fallbackFrom: "x@y.z" },
11
+ ),
12
+ ).toBeNull();
13
+ });
14
+
15
+ test("host present → transport with send()", () => {
16
+ const t = createSmtpTransportFromEnv({ SMTP_HOST: "localhost" }, { fallbackFrom: "x@y.z" });
17
+ expect(t).not.toBeNull();
18
+ expect(typeof t?.send).toBe("function");
19
+ });
20
+
21
+ test("full env (port coercion + auth) builds without throwing", () => {
22
+ const t = createSmtpTransportFromEnv(
23
+ {
24
+ SMTP_HOST: "smtp.example.com",
25
+ SMTP_PORT: "465",
26
+ SMTP_SECURE: "true",
27
+ SMTP_USER: "user",
28
+ SMTP_PASS: "pass",
29
+ SMTP_FROM: "App <noreply@example.com>",
30
+ },
31
+ { fallbackFrom: "x@y.z" },
32
+ );
33
+ expect(typeof t?.send).toBe("function");
34
+ });
35
+ });
@@ -1,6 +1,11 @@
1
1
  import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
- import type { DeliveryChannel, NotificationRenderer } from "../delivery";
3
+ import type {
4
+ ChannelMessage,
5
+ DeliveryChannel,
6
+ NotificationRenderer,
7
+ RenderedMessage,
8
+ } from "../delivery";
4
9
  import type { EmailTransport } from "./types";
5
10
 
6
11
  export type EmailChannelOptions = {
@@ -15,33 +20,40 @@ export type EmailChannelOptions = {
15
20
  export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
16
21
  const { transport, renderer, resolveEmail } = options;
17
22
 
23
+ // Render is the expensive step (template engine, possibly a remote service)
24
+ // and runs in the delivery.render job, decoupled from the SMTP send so each
25
+ // can retry independently. Extracted so the inline fallback (no job runner)
26
+ // can reuse it without going through the channel's own render() indirection.
27
+ async function renderMessage(message: ChannelMessage): Promise<RenderedMessage> {
28
+ // Build renderer input: per-channel template data (if any) or fall back
29
+ // to title/body from the message. Renderer handles both cases.
30
+ const variables = (message.data as DbRow) ?? {
31
+ title: message.title,
32
+ body: message.body,
33
+ };
34
+ const html = await renderer.render({
35
+ template: message.notificationType,
36
+ variables,
37
+ });
38
+ const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
39
+ return { html, subject };
40
+ }
41
+
18
42
  return {
19
43
  name: "email",
44
+ mode: "queued",
20
45
 
21
46
  async resolve(userId, ctx) {
22
47
  return resolveEmail(userId, ctx);
23
48
  },
24
49
 
25
- async send(address, message, _ctx) {
26
- // Build renderer input: per-channel template data (if any) or fall back
27
- // to title/body from the message. Renderer handles both cases.
28
- const variables = (message.data as DbRow) ?? {
29
- title: message.title,
30
- body: message.body,
31
- };
32
-
33
- const html = await renderer.render({
34
- template: message.notificationType,
35
- variables,
36
- });
37
- const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
38
-
39
- await transport.send({
40
- to: address,
41
- subject,
42
- html,
43
- });
50
+ render(message, _ctx) {
51
+ return renderMessage(message);
52
+ },
44
53
 
54
+ async send(address, message, _ctx, rendered) {
55
+ const { html, subject } = rendered ?? (await renderMessage(message));
56
+ await transport.send({ to: address, subject, html });
45
57
  return { status: "sent", address };
46
58
  },
47
59
  };
@@ -16,7 +16,9 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
16
16
  r.requires("delivery");
17
17
 
18
18
  r.useExtension("deliveryChannel", "email", {
19
+ mode: channel.mode,
19
20
  resolve: channel.resolve,
21
+ render: channel.render,
20
22
  send: channel.send,
21
23
  });
22
24
  });
@@ -1,4 +1,9 @@
1
1
  export { createEmailChannel, type EmailChannelOptions } from "./email-channel";
2
2
  export { createChannelEmailFeature } from "./feature";
3
- export { createSmtpTransport, type SmtpTransportOptions } from "./smtp-transport";
3
+ export {
4
+ createSmtpTransport,
5
+ createSmtpTransportFromEnv,
6
+ type SmtpEnv,
7
+ type SmtpTransportOptions,
8
+ } from "./smtp-transport";
4
9
  export { createInMemoryTransport, type EmailMessage, type EmailTransport } from "./types";
@@ -63,3 +63,40 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
63
63
  },
64
64
  };
65
65
  }
66
+
67
+ /** Env-Felder die `createSmtpTransportFromEnv` liest. Apps die SMTP
68
+ * nutzen extenden ihr env-Schema um genau diese Keys. */
69
+ export type SmtpEnv = {
70
+ readonly SMTP_HOST?: string;
71
+ readonly SMTP_PORT?: string;
72
+ readonly SMTP_SECURE?: string;
73
+ readonly SMTP_USER?: string;
74
+ readonly SMTP_PASS?: string;
75
+ readonly SMTP_FROM?: string;
76
+ };
77
+
78
+ /**
79
+ * Boot-Time-Transport aus env. Ohne `SMTP_HOST` → `null` (Caller behandelt
80
+ * das als "kein Mail-Versand", statt zu crashen). Port wird zu Number
81
+ * gecoerced (default 587), `secure` ist `SMTP_SECURE === "true"`, auth nur
82
+ * wenn user UND pass gesetzt sind.
83
+ *
84
+ * Ersetzt den identischen `env.SMTP_HOST ? createSmtpTransport({...}) : null`
85
+ * Block den jede App in bin/main.ts + bin/server.ts hand-rollte — nur die
86
+ * From-Default (`fallbackFrom`) variiert pro App.
87
+ */
88
+ export function createSmtpTransportFromEnv(
89
+ env: SmtpEnv,
90
+ opts: { readonly fallbackFrom: string },
91
+ ): EmailTransport | null {
92
+ if (!env.SMTP_HOST) return null;
93
+ return createSmtpTransport({
94
+ host: env.SMTP_HOST,
95
+ port: env.SMTP_PORT ? Number(env.SMTP_PORT) : 587,
96
+ secure: env.SMTP_SECURE === "true",
97
+ ...(env.SMTP_USER && env.SMTP_PASS
98
+ ? { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } }
99
+ : {}),
100
+ from: env.SMTP_FROM ?? opts.fallbackFrom,
101
+ });
102
+ }
@@ -19,6 +19,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
19
19
 
20
20
  // Register as delivery channel via extension system
21
21
  r.useExtension("deliveryChannel", "inApp", {
22
+ mode: inAppChannel.mode,
22
23
  resolve: inAppChannel.resolve,
23
24
  send: inAppChannel.send,
24
25
  });
@@ -4,6 +4,7 @@ import { inAppMessagesTable } from "./tables";
4
4
 
5
5
  export const inAppChannel: DeliveryChannel = {
6
6
  name: "inApp",
7
+ mode: "inline",
7
8
 
8
9
  async resolve(userId) {
9
10
  // InApp always resolves — the userId IS the address
@@ -16,6 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
16
16
  r.requires("delivery");
17
17
 
18
18
  r.useExtension("deliveryChannel", "push", {
19
+ mode: channel.mode,
19
20
  resolve: channel.resolve,
20
21
  send: channel.send,
21
22
  });
@@ -15,6 +15,7 @@ export function createPushChannel(options: PushChannelOptions): DeliveryChannel
15
15
 
16
16
  return {
17
17
  name: "push",
18
+ mode: "queued",
18
19
 
19
20
  async resolve(userId, ctx) {
20
21
  return resolveToken(userId, ctx);
@@ -10,6 +10,7 @@ import {
10
10
  type NotifyFn,
11
11
  qn,
12
12
  } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
13
14
  import {
14
15
  createTestUser,
15
16
  setupTestStack,
@@ -35,9 +36,10 @@ import { TenantQueries } from "../../tenant/constants";
35
36
  import { createTenantFeature } from "../../tenant/feature";
36
37
  import { tenantMembershipsTable } from "../../tenant/membership-table";
37
38
  import { tenantEntity } from "../../tenant/schema/tenant";
38
- import { DeliveryHandlers, DeliveryQueries } from "../constants";
39
+ import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
39
40
  import { collectChannels, createDeliveryService } from "../delivery-service";
40
41
  import { createDeliveryFeature } from "../feature";
42
+ import { deliveryRenderJob } from "../jobs";
41
43
  import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
42
44
  import { createDeliveryTestContext } from "../testing";
43
45
  import type { DeliveryService } from "../types";
@@ -1319,3 +1321,247 @@ describe("flow 16: repeated unsubscribe clicks are idempotent", () => {
1319
1321
  expect(rows[0]?.["enabled"]).toBe(false);
1320
1322
  });
1321
1323
  });
1324
+
1325
+ // --- Flow 17: async delivery via delivery.render → delivery.send jobs ---
1326
+
1327
+ // Dedicated recipient id, untouched by earlier tests, so no stale preference
1328
+ // or unsubscribe row disables a channel under us. String id — notify() treats a
1329
+ // non-string `to` as a broadcast target ("tenant" in to).
1330
+ const asyncRecipient = "7";
1331
+
1332
+ async function waitFor(check: () => Promise<void>, timeoutMs = 10000): Promise<void> {
1333
+ const start = Date.now();
1334
+ let lastErr: unknown;
1335
+ for (;;) {
1336
+ try {
1337
+ await check();
1338
+ return;
1339
+ } catch (err) {
1340
+ lastErr = err;
1341
+ if (Date.now() - start > timeoutMs) throw lastErr;
1342
+ await new Promise((resolve) => setTimeout(resolve, 50));
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ function makeStubRunner(): {
1348
+ runner: JobRunner;
1349
+ dispatched: Array<{
1350
+ name: string;
1351
+ payload: Record<string, unknown>;
1352
+ meta?: Record<string, unknown>;
1353
+ }>;
1354
+ } {
1355
+ const dispatched: Array<{
1356
+ name: string;
1357
+ payload: Record<string, unknown>;
1358
+ meta?: Record<string, unknown>;
1359
+ }> = [];
1360
+ const runner = {
1361
+ async start() {},
1362
+ async stop() {},
1363
+ async dispatch(
1364
+ name: string,
1365
+ payload?: Record<string, unknown>,
1366
+ meta?: Record<string, unknown>,
1367
+ ) {
1368
+ dispatched.push({ name, payload: payload ?? {}, ...(meta !== undefined && { meta }) });
1369
+ return "stub-job-id";
1370
+ },
1371
+ async handleEvent() {},
1372
+ } as unknown as JobRunner;
1373
+ return { runner, dispatched };
1374
+ }
1375
+
1376
+ describe("flow 17: async render→send pipeline", () => {
1377
+ test("with a real job runner, email + push deliver via delivery.render → delivery.send", async () => {
1378
+ const redisUrl = process.env["REDIS_URL"];
1379
+ if (!redisUrl) throw new Error("REDIS_URL required for the async delivery e2e test");
1380
+ const queueNamePrefix = `kumiko-delivery-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1381
+ const jobRunner = createJobRunner({
1382
+ registry: stack.registry,
1383
+ context: { db },
1384
+ redisUrl,
1385
+ consumerLane: "worker",
1386
+ queueNamePrefix,
1387
+ });
1388
+ await jobRunner.start();
1389
+ try {
1390
+ const asyncService = createDeliveryService({
1391
+ db,
1392
+ registry: stack.registry,
1393
+ channels: collectChannels(stack.registry),
1394
+ tenantUserIdsQuery: TenantQueries.resolveUserIds,
1395
+ jobRunner,
1396
+ });
1397
+
1398
+ await asyncService.notify(
1399
+ "app:notify:async-e2e",
1400
+ {
1401
+ to: asyncRecipient,
1402
+ data: { title: "Async", body: "delivered via jobs", subject: "Async Subject" },
1403
+ },
1404
+ admin,
1405
+ admin.tenantId,
1406
+ );
1407
+
1408
+ // Email: queued → render job → send job → sent. Proves the framework
1409
+ // jobRunner-into-job-ctx injection (render dispatches send) end-to-end.
1410
+ await waitFor(async () => {
1411
+ const rows = await selectMany(db, deliveryAttemptsTable, {
1412
+ notificationType: "app:notify:async-e2e",
1413
+ channel: "email",
1414
+ });
1415
+ expect(rows.some((r) => r["status"] === "sent")).toBe(true);
1416
+ });
1417
+ const email = emailTransport.sent.find((m) => m.to === testEmail(asyncRecipient));
1418
+ expect(email).toBeDefined();
1419
+ expect(email?.subject).toBe("Async Subject");
1420
+ expect(email?.html).toContain("delivered via jobs");
1421
+ expect(email?.html).toContain("<!DOCTYPE html>");
1422
+
1423
+ // Push: no render step → dispatched straight to delivery.send.
1424
+ await waitFor(async () => {
1425
+ const rows = await selectMany(db, deliveryAttemptsTable, {
1426
+ notificationType: "app:notify:async-e2e",
1427
+ channel: "push",
1428
+ });
1429
+ expect(rows.some((r) => r["status"] === "sent")).toBe(true);
1430
+ });
1431
+ expect(
1432
+ pushTransport.sent.find((m) => m.token === testPushToken(asyncRecipient)),
1433
+ ).toBeDefined();
1434
+ } finally {
1435
+ await jobRunner.stop();
1436
+ }
1437
+ }, 20000);
1438
+
1439
+ test("queued channels dispatch jobs instead of sending inline; inApp stays inline", async () => {
1440
+ const { runner, dispatched } = makeStubRunner();
1441
+ const asyncService = createDeliveryService({
1442
+ db,
1443
+ registry: stack.registry,
1444
+ channels: collectChannels(stack.registry),
1445
+ tenantUserIdsQuery: TenantQueries.resolveUserIds,
1446
+ jobRunner: runner,
1447
+ });
1448
+
1449
+ await asyncService.notify(
1450
+ "app:notify:queued-dispatch",
1451
+ { to: asyncRecipient, data: { title: "Q", body: "Q", subject: "Q" } },
1452
+ admin,
1453
+ admin.tenantId,
1454
+ );
1455
+
1456
+ // email has a render step → delivery.render; push has none → delivery.send.
1457
+ const renderDispatch = dispatched.find((d) => d.name === DeliveryJobs.render);
1458
+ expect(renderDispatch?.payload["channelName"]).toBe("email");
1459
+ expect(renderDispatch?.payload["deliveryAttemptId"]).toBeDefined();
1460
+ expect(
1461
+ dispatched.some((d) => d.name === DeliveryJobs.send && d.payload["channelName"] === "push"),
1462
+ ).toBe(true);
1463
+
1464
+ // email is NOT sent inline — it waits for the job.
1465
+ expect(emailTransport.sent.find((m) => m.to === testEmail(asyncRecipient))).toBeUndefined();
1466
+
1467
+ // email attempt row exists as "queued"; inApp was delivered inline ("sent").
1468
+ const emailRows = await selectMany(db, deliveryAttemptsTable, {
1469
+ notificationType: "app:notify:queued-dispatch",
1470
+ channel: "email",
1471
+ });
1472
+ expect(emailRows.some((r) => r["status"] === "queued")).toBe(true);
1473
+ const inAppRows = await selectMany(db, deliveryAttemptsTable, {
1474
+ notificationType: "app:notify:queued-dispatch",
1475
+ channel: "inApp",
1476
+ });
1477
+ expect(inAppRows.some((r) => r["status"] === "sent")).toBe(true);
1478
+ });
1479
+
1480
+ test("without a job runner, queued channels fall back to synchronous inline send", async () => {
1481
+ const inlineService = createDeliveryService({
1482
+ db,
1483
+ registry: stack.registry,
1484
+ channels: collectChannels(stack.registry),
1485
+ tenantUserIdsQuery: TenantQueries.resolveUserIds,
1486
+ });
1487
+
1488
+ await inlineService.notify(
1489
+ "app:notify:inline-fallback",
1490
+ { to: asyncRecipient, data: { title: "Inline", body: "sent inline", subject: "Inline" } },
1491
+ admin,
1492
+ admin.tenantId,
1493
+ );
1494
+
1495
+ // Sent synchronously during notify() — no job runner, no waiting.
1496
+ expect(emailTransport.sent.find((m) => m.to === testEmail(asyncRecipient))).toBeDefined();
1497
+ const emailRows = await selectMany(db, deliveryAttemptsTable, {
1498
+ notificationType: "app:notify:inline-fallback",
1499
+ channel: "email",
1500
+ });
1501
+ expect(emailRows.some((r) => r["status"] === "sent")).toBe(true);
1502
+ });
1503
+
1504
+ test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
1505
+ const { runner, dispatched } = makeStubRunner();
1506
+ const attemptId = "00000000-0000-4000-8000-0000000000aa";
1507
+ await expect(
1508
+ deliveryRenderJob(
1509
+ {
1510
+ channelName: "push", // push has no render() — render job must not be used for it
1511
+ address: testPushToken(asyncRecipient),
1512
+ tenantId: admin.tenantId,
1513
+ recipientId: String(asyncRecipient),
1514
+ notificationType: "app:notify:render-isolation",
1515
+ deliveryAttemptId: attemptId,
1516
+ priority: "normal",
1517
+ message: { notificationType: "app:notify:render-isolation", title: "X" },
1518
+ },
1519
+ { db, registry: stack.registry, jobRunner: runner },
1520
+ ),
1521
+ ).rejects.toThrow(/no render step/);
1522
+
1523
+ // No send dispatched, and the attempt is recorded as failed (not stuck queued).
1524
+ expect(dispatched).toHaveLength(0);
1525
+ const rows = await selectMany(db, deliveryAttemptsTable, {
1526
+ notificationType: "app:notify:render-isolation",
1527
+ });
1528
+ expect(rows.some((r) => r["status"] === "failed")).toBe(true);
1529
+ });
1530
+ });
1531
+
1532
+ // --- Flow 18: priority maps onto the BullMQ job priority ---
1533
+
1534
+ describe("flow 18: priority → job priority mapping", () => {
1535
+ test("critical/normal/low map to ascending BullMQ priorities on dispatch", async () => {
1536
+ const send = async (notificationType: string, priority: "critical" | "normal" | "low") => {
1537
+ const { runner, dispatched } = makeStubRunner();
1538
+ const asyncService = createDeliveryService({
1539
+ db,
1540
+ registry: stack.registry,
1541
+ channels: collectChannels(stack.registry),
1542
+ tenantUserIdsQuery: TenantQueries.resolveUserIds,
1543
+ jobRunner: runner,
1544
+ });
1545
+ await asyncService.notify(
1546
+ notificationType,
1547
+ { to: asyncRecipient, data: { title: "P", body: "P" }, priority },
1548
+ admin,
1549
+ admin.tenantId,
1550
+ );
1551
+ // email → delivery.render carries the priority meta.
1552
+ const render = dispatched.find((d) => d.name === DeliveryJobs.render);
1553
+ return render?.meta?.["priority"];
1554
+ };
1555
+
1556
+ const critical = await send("app:notify:prio-critical", "critical");
1557
+ const normal = await send("app:notify:prio-normal", "normal");
1558
+ const low = await send("app:notify:prio-low", "low");
1559
+
1560
+ expect(critical).toBe(1);
1561
+ expect(normal).toBe(2);
1562
+ expect(low).toBe(3);
1563
+ // Lower number = higher priority: critical jumps ahead of normal ahead of low.
1564
+ expect(critical as number).toBeLessThan(normal as number);
1565
+ expect(normal as number).toBeLessThan(low as number);
1566
+ });
1567
+ });
@@ -0,0 +1,54 @@
1
+ // Shared delivery-attempt event writer. Used both synchronously by the
2
+ // delivery-service (inline channels, skips) and asynchronously by the
3
+ // delivery.render / delivery.send job handlers — keeping the append +
4
+ // inline-projection logic in one place so both paths produce identical rows.
5
+
6
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { append, getStreamVersion } from "@cosmicdrift/kumiko-framework/event-store";
9
+ import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
10
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
11
+ import { DELIVERY_ATTEMPT_EVENT } from "./constants";
12
+ import { deliveryAttemptSchema } from "./events";
13
+ import type { DeliveryLogEntry } from "./types";
14
+
15
+ // Append one delivery-attempt event to `attemptId`'s stream and run the inline
16
+ // `delivery-log` projection in the same write (low-level append() does not
17
+ // auto-fire projections — only the dispatcher/executor paths do). getStreamVersion
18
+ // returns 0 for a fresh stream, so the same call serves both the first event
19
+ // (queued, or a single-shot terminal) and follow-ups (queued → sent/failed).
20
+ export async function appendAttemptEvent(
21
+ db: DbConnection,
22
+ registry: Registry,
23
+ attemptId: string,
24
+ entry: DeliveryLogEntry,
25
+ ): Promise<void> {
26
+ const { tenantId, ...rest } = entry;
27
+ // Schema-parse to match ctx.appendEvent's guarantee: a payload drift between
28
+ // service/job + feature-registration fails loudly here instead of landing on
29
+ // the events-table and crashing a consumer later.
30
+ const payload = deliveryAttemptSchema.parse(rest);
31
+ const expectedVersion = await getStreamVersion(db, attemptId, tenantId);
32
+ const stored = await append(db, {
33
+ aggregateId: attemptId,
34
+ aggregateType: "deliveryAttempt",
35
+ tenantId,
36
+ expectedVersion,
37
+ type: DELIVERY_ATTEMPT_EVENT,
38
+ payload,
39
+ metadata: { userId: "system" },
40
+ });
41
+ await runProjectionsForEvent(stored, registry, db);
42
+ }
43
+
44
+ // Single-shot terminal log (inline channels, skips, idempotency dups): fresh
45
+ // aggregate id, one event.
46
+ export async function logAttempt(
47
+ db: DbConnection,
48
+ registry: Registry,
49
+ entry: DeliveryLogEntry,
50
+ ): Promise<string> {
51
+ const attemptId = generateId();
52
+ await appendAttemptEvent(db, registry, attemptId, entry);
53
+ return attemptId;
54
+ }