@cosmicdrift/kumiko-bundled-features 0.98.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.
- package/package.json +7 -6
- package/src/auth-email-password/auth-mailer.ts +28 -12
- package/src/auth-email-password/index.ts +3 -0
- package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
- package/src/channel-email/email-channel.ts +32 -20
- package/src/channel-email/feature.ts +2 -0
- package/src/channel-email/index.ts +6 -1
- package/src/channel-email/smtp-transport.ts +37 -0
- package/src/channel-in-app/feature.ts +1 -0
- package/src/channel-in-app/in-app-channel.ts +1 -0
- package/src/channel-push/feature.ts +1 -0
- package/src/channel-push/push-channel.ts +1 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
- package/src/delivery/attempt-log.ts +54 -0
- package/src/delivery/channel-context.ts +17 -0
- package/src/delivery/constants.ts +35 -1
- package/src/delivery/delivery-service.ts +118 -47
- package/src/delivery/events.ts +7 -1
- package/src/delivery/feature.ts +38 -14
- package/src/delivery/index.ts +3 -0
- package/src/delivery/jobs.ts +153 -0
- package/src/delivery/tables.ts +5 -1
- package/src/delivery/types.ts +25 -2
- package/src/file-foundation/feature.ts +28 -141
- package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
- package/src/presets/dsgvo-self-service.ts +30 -0
- package/src/presets/index.ts +1 -0
- package/src/secrets/feature.ts +3 -1
- package/src/secrets/index.ts +1 -0
- package/src/tags/constants.ts +6 -0
- package/src/tags/index.ts +1 -0
- package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +6 -0
- package/src/tags/web/i18n.ts +2 -2
- package/src/tags/web/index.ts +2 -0
- package/src/tags/web/tag-filter.tsx +24 -12
- package/src/tags/web/tags-cell.tsx +46 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
- package/src/text-content/seeding.ts +35 -1
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
- 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.
|
|
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.
|
|
95
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-renderer-web": "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
|
|
66
|
-
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
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
|
});
|
|
@@ -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
|
+
}
|