@cosmicdrift/kumiko-samples 0.339.0 → 0.341.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 +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
export { createChannelTelegramFeature } from "./feature.js";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
createTelegramChannel,
|
|
4
|
+
isTelegramBotToken,
|
|
5
|
+
isTelegramChatId,
|
|
6
|
+
TELEGRAM_DEFAULT_API_BASE_URL,
|
|
7
|
+
TELEGRAM_SECRET_KEYS,
|
|
8
|
+
type TelegramChannelOptions,
|
|
9
|
+
} from "./telegram-channel.js";
|
|
@@ -10,30 +10,65 @@ import {
|
|
|
10
10
|
|
|
11
11
|
export type TelegramChannelOptions = {
|
|
12
12
|
// App-level override (tests, self-hosted Bot API server); never tenant config.
|
|
13
|
+
// Its host must be listed in `allowedHosts`, otherwise boot fails.
|
|
13
14
|
readonly apiBaseUrl?: string;
|
|
15
|
+
// Replaces the default allowlist (`api.telegram.org`); required for a self-hosted Bot API server.
|
|
16
|
+
readonly allowedHosts?: readonly string[];
|
|
17
|
+
// Default true; `false` only for local http test servers.
|
|
18
|
+
readonly requireHttps?: boolean;
|
|
14
19
|
readonly timeoutMs?: number;
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
export const TELEGRAM_DEFAULT_API_BASE_URL = "https://api.telegram.org";
|
|
23
|
+
export const TELEGRAM_DEFAULT_ALLOWED_HOSTS: readonly string[] = ["api.telegram.org"];
|
|
18
24
|
export const TELEGRAM_TEXT_MAX_CHARS = 4096;
|
|
25
|
+
export const TELEGRAM_SECRET_KEYS = { botToken: "channel-telegram:secret:bot-token" } as const;
|
|
19
26
|
|
|
20
27
|
// Numeric chat/group id (groups and channels are negative) or a public @channelname.
|
|
21
28
|
const TELEGRAM_CHAT_ID_PATTERN = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{3,31})$/;
|
|
22
29
|
// `<bot id>:<secret>`; the token is spliced into the URL path, so only the real shape passes.
|
|
23
30
|
const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{1,20}:[A-Za-z0-9_-]{10,}$/;
|
|
24
31
|
|
|
32
|
+
export function isTelegramChatId(address: string): boolean {
|
|
33
|
+
return TELEGRAM_CHAT_ID_PATTERN.test(address);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isTelegramBotToken(token: string): boolean {
|
|
37
|
+
return TELEGRAM_BOT_TOKEN_PATTERN.test(token);
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
export function createTelegramChannel(
|
|
26
41
|
options: TelegramChannelOptions,
|
|
27
42
|
botTokenKey: string,
|
|
28
43
|
): DeliveryChannel {
|
|
29
44
|
const apiBaseUrl = options.apiBaseUrl ?? TELEGRAM_DEFAULT_API_BASE_URL;
|
|
45
|
+
const allowedHosts = options.allowedHosts ?? TELEGRAM_DEFAULT_ALLOWED_HOSTS;
|
|
46
|
+
const requireHttps = options.requireHttps ?? true;
|
|
47
|
+
|
|
48
|
+
// Fail at boot: a misconfigured base URL must not surface as per-send failures.
|
|
49
|
+
let parsedBase: URL;
|
|
50
|
+
try {
|
|
51
|
+
parsedBase = new URL(apiBaseUrl);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error("channel-telegram: apiBaseUrl is not a valid URL");
|
|
54
|
+
}
|
|
55
|
+
if (!allowedHosts.includes(parsedBase.hostname)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`channel-telegram: apiBaseUrl host "${parsedBase.hostname}" is not in allowedHosts`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (requireHttps && parsedBase.protocol !== "https:") {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"channel-telegram: apiBaseUrl must use https (set requireHttps: false to opt out)",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
30
65
|
|
|
31
66
|
return {
|
|
32
67
|
name: "telegram",
|
|
33
68
|
mode: "queued",
|
|
34
69
|
|
|
35
70
|
async send(address, message, ctx) {
|
|
36
|
-
if (!
|
|
71
|
+
if (!isTelegramChatId(address)) {
|
|
37
72
|
return toChannelResult(address, { ok: false, code: "invalid_address" });
|
|
38
73
|
}
|
|
39
74
|
let token: string | undefined;
|
|
@@ -46,21 +81,14 @@ export function createTelegramChannel(
|
|
|
46
81
|
} catch {
|
|
47
82
|
token = undefined;
|
|
48
83
|
}
|
|
49
|
-
if (!token || !
|
|
84
|
+
if (!token || !isTelegramBotToken(token)) {
|
|
50
85
|
return toChannelResult(address, { ok: false, code: "missing_credentials" });
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
// The base URL is the allowlist: only its own host is reachable.
|
|
54
|
-
let baseHost: string;
|
|
55
|
-
try {
|
|
56
|
-
baseHost = new URL(apiBaseUrl).hostname;
|
|
57
|
-
} catch {
|
|
58
|
-
return toChannelResult(address, { ok: false, code: "host_not_allowed" });
|
|
59
|
-
}
|
|
60
88
|
const result = await postChatWebhook({
|
|
61
89
|
url: `${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/sendMessage`,
|
|
62
|
-
allowedHosts
|
|
63
|
-
requireHttps
|
|
90
|
+
allowedHosts,
|
|
91
|
+
requireHttps,
|
|
64
92
|
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
65
93
|
// No parse_mode: Telegram renders the text literally.
|
|
66
94
|
body: {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.341.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Chat channels deliver in production boot, ctx.notify returns a NotifyResult",
|
|
6
|
+
"detail": "Chat channels (slack, discord, teams, telegram) now deliver in production boot. `runProdApp`, `runDevApp` and `runWorkerApp` pass the tenant secrets to the delivery service and hand queued channels to the `delivery.render`/`delivery.send` jobs of the calling context's job runner. `delivery.render` now receives `ctx.secrets` as well. Without a job runner, queued channels still deliver inline, now with secrets. `runBootstrap` is a one-shot process whose queue nobody drains after it exits, so it keeps delivering queued channels inline (the SystemAdmin invitation goes out before the process ends).\n`NotifyFn` returns a `NotifyResult` (`{ deliveries }` with channel, recipientId, status `queued | sent | failed | skipped`, error and `deliveryAttemptId` per delivery) instead of `void`. `NotifyOptions.immediate` delivers queued channels inline for one call and bypasses job retry, for \"send test message\" handlers. `NotifyFactory` takes an optional job dispatcher as third argument, and `DeliveryService.notify` an optional per-call dispatcher.\nConsumers: `NotifyFn` implementations in tests and mocks must now return a `NotifyResult`, e.g. `async () => ({ deliveries: [] })`. When running the API without a worker (`runSingleInstance: false`), a dedicated worker must process the delivery jobs.",
|
|
7
|
+
"migration": "NotifyFn implementations in tests and mocks must return a NotifyResult, e.g. async () => ({ deliveries: [] }). Queued channels now run through the delivery jobs in production; an API-only deployment (runSingleInstance: false) needs a worker that mounts delivery and the channel features."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.339.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
2
2
|
import {
|
|
3
3
|
type ChatSendResult,
|
|
4
|
+
type ChatWebhookTarget,
|
|
4
5
|
chatConnectionNameSchema,
|
|
5
6
|
DEFAULT_CHAT_TIMEOUT_MS,
|
|
6
7
|
postChatWebhook,
|
|
@@ -25,6 +26,22 @@ export function chatMessageText(message: ChannelMessage): string {
|
|
|
25
26
|
return message.body ? `${message.title}\n${message.body}` : message.title;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
export function resolveChatWebhookTarget(
|
|
30
|
+
provider: {
|
|
31
|
+
readonly defaultAllowedHosts: readonly string[];
|
|
32
|
+
readonly requiredPathPrefix?: string;
|
|
33
|
+
},
|
|
34
|
+
options: ChatWebhookChannelOptions,
|
|
35
|
+
): ChatWebhookTarget {
|
|
36
|
+
return {
|
|
37
|
+
allowedHosts: options.allowedHosts ?? provider.defaultAllowedHosts,
|
|
38
|
+
requireHttps: options.requireHttps ?? true,
|
|
39
|
+
...(provider.requiredPathPrefix !== undefined && {
|
|
40
|
+
requiredPathPrefix: provider.requiredPathPrefix,
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
export type ChatWebhookChannelSpec = {
|
|
29
46
|
readonly name: string;
|
|
30
47
|
readonly featureName: string;
|
|
@@ -62,11 +79,7 @@ export function createChatWebhookChannel(spec: ChatWebhookChannelSpec): Delivery
|
|
|
62
79
|
|
|
63
80
|
const result = await postChatWebhook({
|
|
64
81
|
url,
|
|
65
|
-
|
|
66
|
-
requireHttps: options.requireHttps ?? true,
|
|
67
|
-
...(spec.requiredPathPrefix !== undefined && {
|
|
68
|
-
requiredPathPrefix: spec.requiredPathPrefix,
|
|
69
|
-
}),
|
|
82
|
+
...resolveChatWebhookTarget(spec, options),
|
|
70
83
|
timeoutMs: options.timeoutMs ?? DEFAULT_CHAT_TIMEOUT_MS,
|
|
71
84
|
body: spec.buildBody(message),
|
|
72
85
|
});
|
|
@@ -28,12 +28,15 @@ export const chatConnectionNameSchema = z
|
|
|
28
28
|
.max(64)
|
|
29
29
|
.regex(/^[a-z0-9][a-z0-9-]*$/);
|
|
30
30
|
|
|
31
|
-
export type
|
|
32
|
-
readonly url: string;
|
|
31
|
+
export type ChatWebhookTarget = {
|
|
33
32
|
// Exact hostnames, or suffixes with a leading dot (".webhook.office.com").
|
|
34
33
|
readonly allowedHosts: readonly string[];
|
|
35
34
|
readonly requireHttps: boolean;
|
|
36
35
|
readonly requiredPathPrefix?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ChatWebhookRequest = ChatWebhookTarget & {
|
|
39
|
+
readonly url: string;
|
|
37
40
|
readonly timeoutMs: number;
|
|
38
41
|
readonly body: unknown;
|
|
39
42
|
// Test seam; production always uses the policy-bound egress().
|
|
@@ -60,30 +63,36 @@ function hostMatches(hostname: string, allowedHost: string): boolean {
|
|
|
60
63
|
|
|
61
64
|
// Allowlist runs on the parsed URL, never on the raw string: userinfo
|
|
62
65
|
// (`https://hooks.slack.com@evil.com`) and look-alike suffixes must not pass.
|
|
63
|
-
function
|
|
66
|
+
export function checkChatWebhookTarget(target: ChatWebhookTarget, url: string): URL | undefined {
|
|
64
67
|
let parsed: URL;
|
|
65
68
|
try {
|
|
66
|
-
parsed = new URL(
|
|
69
|
+
parsed = new URL(url);
|
|
67
70
|
} catch {
|
|
68
71
|
return undefined;
|
|
69
72
|
}
|
|
70
73
|
const protocolAllowed =
|
|
71
|
-
parsed.protocol === "https:" || (!
|
|
74
|
+
parsed.protocol === "https:" || (!target.requireHttps && parsed.protocol === "http:");
|
|
72
75
|
if (!protocolAllowed) return undefined;
|
|
73
76
|
// egress() has no port rule; a provider webhook over https never needs a custom port.
|
|
74
|
-
if (
|
|
77
|
+
if (target.requireHttps && parsed.port !== "") return undefined;
|
|
75
78
|
if (parsed.username !== "" || parsed.password !== "") return undefined;
|
|
76
79
|
const hostname = parsed.hostname.toLowerCase();
|
|
77
|
-
if (!
|
|
80
|
+
if (!target.allowedHosts.some((allowed) => hostMatches(hostname, allowed))) return undefined;
|
|
78
81
|
if (
|
|
79
|
-
|
|
80
|
-
!parsed.pathname.startsWith(
|
|
82
|
+
target.requiredPathPrefix !== undefined &&
|
|
83
|
+
!parsed.pathname.startsWith(target.requiredPathPrefix)
|
|
81
84
|
) {
|
|
82
85
|
return undefined;
|
|
83
86
|
}
|
|
84
87
|
return parsed;
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
export function chatWebhookUrlSchema(target: ChatWebhookTarget): z.ZodType<string> {
|
|
91
|
+
return z
|
|
92
|
+
.string()
|
|
93
|
+
.refine((url) => checkChatWebhookTarget(target, url) !== undefined, "invalid webhook url");
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
function isTimeout(err: unknown): boolean {
|
|
88
97
|
return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
89
98
|
}
|
|
@@ -104,7 +113,7 @@ function classifyFailure(err: unknown): ChatSendFailureCode {
|
|
|
104
113
|
// Never throws and never surfaces the URL: egress/Bun errors can carry it, so
|
|
105
114
|
// every failure is reduced to a code here.
|
|
106
115
|
export async function postChatWebhook(request: ChatWebhookRequest): Promise<ChatSendResult> {
|
|
107
|
-
const target =
|
|
116
|
+
const target = checkChatWebhookTarget(request, request.url);
|
|
108
117
|
if (!target) return { ok: false, code: "host_not_allowed" };
|
|
109
118
|
|
|
110
119
|
let response: Response;
|
|
@@ -3,14 +3,16 @@ import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
|
3
3
|
import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import type {
|
|
5
5
|
EscapeHatchAuditSink,
|
|
6
|
+
NotifyDelivery,
|
|
7
|
+
NotifyJobDispatcher,
|
|
6
8
|
NotifyPriority,
|
|
7
9
|
Registry,
|
|
8
10
|
TenantId,
|
|
9
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
12
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
-
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
12
13
|
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
13
14
|
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
15
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
14
16
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
15
17
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
16
18
|
import type { Redis } from "ioredis";
|
|
@@ -49,10 +51,14 @@ export type DeliveryServiceOptions = {
|
|
|
49
51
|
// Must be present whenever callers rely on idempotencyKey, otherwise notify()
|
|
50
52
|
// throws at the callsite (silent no-op would be a correctness bug).
|
|
51
53
|
readonly idempotencyRedis?: Redis;
|
|
52
|
-
// Job
|
|
53
|
-
// the delivery.render → delivery.send jobs.
|
|
54
|
-
//
|
|
55
|
-
|
|
54
|
+
// Job dispatcher for async (queued-mode) channels: email/push/chat render+send
|
|
55
|
+
// run in the delivery.render → delivery.send jobs. notify() may override it per
|
|
56
|
+
// call. When neither is present, queued channels fall back to synchronous
|
|
57
|
+
// inline delivery (job-less setups, unit tests).
|
|
58
|
+
readonly jobRunner?: NotifyJobDispatcher;
|
|
59
|
+
// Credentials source for inline-delivered chat channels (webhook URL / bot
|
|
60
|
+
// token). Jobs get ctx.secrets from the job runner instead.
|
|
61
|
+
readonly secrets?: SecretsContext;
|
|
56
62
|
// Attributed escape-hatch audit for resolveUserIdsForTenant's ctx.systemDb —
|
|
57
63
|
// absent means an unattributed warn log (see fallbackEscapeHatchReporter).
|
|
58
64
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
@@ -88,6 +94,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
88
94
|
isChannelKilled,
|
|
89
95
|
idempotencyRedis,
|
|
90
96
|
jobRunner,
|
|
97
|
+
secrets,
|
|
91
98
|
escapeHatchAuditSink,
|
|
92
99
|
log,
|
|
93
100
|
} = options;
|
|
@@ -207,11 +214,34 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
207
214
|
)) as readonly string[];
|
|
208
215
|
}
|
|
209
216
|
|
|
217
|
+
// State of one notify() call: collects the per-channel outcome returned to
|
|
218
|
+
// the caller. Passed explicitly — the service itself is shared across calls.
|
|
219
|
+
type NotifyRun = {
|
|
220
|
+
readonly deliveries: NotifyDelivery[];
|
|
221
|
+
readonly jobDispatcher: NotifyJobDispatcher | undefined;
|
|
222
|
+
readonly locale: string | undefined;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function recordDelivery(
|
|
226
|
+
run: NotifyRun,
|
|
227
|
+
entry: DeliveryLogEntry,
|
|
228
|
+
deliveryAttemptId: string,
|
|
229
|
+
): void {
|
|
230
|
+
run.deliveries.push({
|
|
231
|
+
channel: entry.channel,
|
|
232
|
+
recipientId: entry.recipientId,
|
|
233
|
+
status: entry.status,
|
|
234
|
+
error: entry.error,
|
|
235
|
+
deliveryAttemptId,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
210
239
|
// Single-shot terminal log (inline channels, skips, idempotency dups). Async
|
|
211
240
|
// attempts instead append a queued event up front and a terminal event from
|
|
212
241
|
// the send job — see deliverViaChannel + jobs.ts.
|
|
213
|
-
async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
|
|
214
|
-
await logAttempt(db, registry, entry);
|
|
242
|
+
async function logDelivery(run: NotifyRun, entry: DeliveryLogEntry): Promise<void> {
|
|
243
|
+
const attemptId = await logAttempt(db, registry, entry);
|
|
244
|
+
recordDelivery(run, entry, attemptId);
|
|
215
245
|
}
|
|
216
246
|
|
|
217
247
|
// Deliver one resolved (channel, address) pair. Inline channels (inApp) and
|
|
@@ -220,6 +250,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
220
250
|
// hand off to the delivery.render (or delivery.send) job, which appends the
|
|
221
251
|
// terminal event on the same attempt stream.
|
|
222
252
|
async function deliverViaChannel(args: {
|
|
253
|
+
run: NotifyRun;
|
|
223
254
|
channel: DeliveryChannel;
|
|
224
255
|
address: string;
|
|
225
256
|
message: ChannelMessage;
|
|
@@ -230,6 +261,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
230
261
|
priority: NotifyPriority;
|
|
231
262
|
}): Promise<void> {
|
|
232
263
|
const {
|
|
264
|
+
run,
|
|
233
265
|
channel,
|
|
234
266
|
address,
|
|
235
267
|
message,
|
|
@@ -240,12 +272,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
240
272
|
priority,
|
|
241
273
|
} = args;
|
|
242
274
|
|
|
243
|
-
if (channel.mode === "queued" &&
|
|
275
|
+
if (channel.mode === "queued" && run.jobDispatcher) {
|
|
244
276
|
// Async hand-off: record the attempt as queued, then dispatch the
|
|
245
277
|
// render/send job — the terminal sent/failed event is appended by the
|
|
246
278
|
// job, not here.
|
|
247
279
|
const deliveryAttemptId = generateId();
|
|
248
|
-
|
|
280
|
+
const queuedEntry: DeliveryLogEntry = {
|
|
249
281
|
tenantId,
|
|
250
282
|
notificationType,
|
|
251
283
|
channel: channel.name,
|
|
@@ -254,8 +286,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
254
286
|
status: "queued",
|
|
255
287
|
error: null,
|
|
256
288
|
priority,
|
|
257
|
-
}
|
|
258
|
-
await
|
|
289
|
+
};
|
|
290
|
+
await appendAttemptEvent(db, registry, deliveryAttemptId, queuedEntry);
|
|
291
|
+
await run.jobDispatcher.dispatch(
|
|
259
292
|
channel.render ? DeliveryJobs.render : DeliveryJobs.send,
|
|
260
293
|
{
|
|
261
294
|
channelName: channel.name,
|
|
@@ -269,11 +302,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
269
302
|
},
|
|
270
303
|
{ priority: deliveryPriorityRank[priority] },
|
|
271
304
|
);
|
|
305
|
+
recordDelivery(run, queuedEntry, deliveryAttemptId);
|
|
272
306
|
} else {
|
|
273
307
|
// Inline (inApp) or no-job-runner fallback: render + send synchronously.
|
|
274
308
|
const rendered = channel.render ? await channel.render(message, channelCtx) : undefined;
|
|
275
309
|
const result = await channel.send(address, message, channelCtx, rendered);
|
|
276
|
-
await logDelivery({
|
|
310
|
+
await logDelivery(run, {
|
|
277
311
|
tenantId,
|
|
278
312
|
notificationType,
|
|
279
313
|
channel: channel.name,
|
|
@@ -290,6 +324,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
290
324
|
notificationType: string,
|
|
291
325
|
data: Readonly<Record<string, unknown>> | undefined,
|
|
292
326
|
channelName: string,
|
|
327
|
+
locale: string | undefined,
|
|
293
328
|
): ChannelMessage {
|
|
294
329
|
// Look up per-channel template from notification definition
|
|
295
330
|
const notifDef = registry.getAllNotifications().get(notificationType);
|
|
@@ -303,6 +338,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
303
338
|
title: (channelData["title"] as string) ?? (data["title"] as string) ?? notificationType,
|
|
304
339
|
body: channelData["body"] as string | undefined,
|
|
305
340
|
data: channelData,
|
|
341
|
+
locale,
|
|
306
342
|
};
|
|
307
343
|
}
|
|
308
344
|
|
|
@@ -312,6 +348,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
312
348
|
title: (data?.["title"] as string) ?? notificationType,
|
|
313
349
|
body: data?.["body"] as string | undefined,
|
|
314
350
|
data,
|
|
351
|
+
locale,
|
|
315
352
|
};
|
|
316
353
|
}
|
|
317
354
|
|
|
@@ -371,20 +408,21 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
371
408
|
data: Readonly<Record<string, unknown>> | undefined,
|
|
372
409
|
tenantId: TenantId,
|
|
373
410
|
priority: NotifyPriority,
|
|
411
|
+
run: NotifyRun,
|
|
374
412
|
): Promise<void> {
|
|
375
|
-
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
|
|
413
|
+
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
376
414
|
|
|
377
415
|
for (const channel of channels) {
|
|
378
416
|
// Route-only channel (no per-user address): not a user-notification target.
|
|
379
417
|
if (!channel.resolve) continue;
|
|
380
418
|
|
|
381
|
-
const message = buildMessage(notificationType, data, channel.name);
|
|
419
|
+
const message = buildMessage(notificationType, data, channel.name, run.locale);
|
|
382
420
|
|
|
383
421
|
// Kill switch: tenant admin disabled this channel entirely
|
|
384
422
|
if (isChannelKilled) {
|
|
385
423
|
const killed = await isChannelKilled(tenantId, channel.name);
|
|
386
424
|
if (killed) {
|
|
387
|
-
await logDelivery({
|
|
425
|
+
await logDelivery(run, {
|
|
388
426
|
tenantId,
|
|
389
427
|
notificationType,
|
|
390
428
|
channel: channel.name,
|
|
@@ -402,7 +440,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
402
440
|
if (priority !== "critical") {
|
|
403
441
|
const enabled = await isChannelEnabled(userId, tenantId, notificationType, channel.name);
|
|
404
442
|
if (!enabled) {
|
|
405
|
-
await logDelivery({
|
|
443
|
+
await logDelivery(run, {
|
|
406
444
|
tenantId,
|
|
407
445
|
notificationType,
|
|
408
446
|
channel: channel.name,
|
|
@@ -420,7 +458,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
420
458
|
if (rateLimit) {
|
|
421
459
|
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
422
460
|
if (!allowed) {
|
|
423
|
-
await logDelivery({
|
|
461
|
+
await logDelivery(run, {
|
|
424
462
|
tenantId,
|
|
425
463
|
notificationType,
|
|
426
464
|
channel: channel.name,
|
|
@@ -437,7 +475,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
437
475
|
try {
|
|
438
476
|
const address = await channel.resolve(userId, channelCtx);
|
|
439
477
|
if (!address) {
|
|
440
|
-
await logDelivery({
|
|
478
|
+
await logDelivery(run, {
|
|
441
479
|
tenantId,
|
|
442
480
|
notificationType,
|
|
443
481
|
channel: channel.name,
|
|
@@ -454,7 +492,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
454
492
|
priority !== "critical" &&
|
|
455
493
|
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
456
494
|
) {
|
|
457
|
-
await logDelivery({
|
|
495
|
+
await logDelivery(run, {
|
|
458
496
|
tenantId,
|
|
459
497
|
notificationType,
|
|
460
498
|
channel: channel.name,
|
|
@@ -468,6 +506,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
468
506
|
}
|
|
469
507
|
|
|
470
508
|
await deliverViaChannel({
|
|
509
|
+
run,
|
|
471
510
|
channel,
|
|
472
511
|
address,
|
|
473
512
|
message,
|
|
@@ -478,7 +517,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
478
517
|
priority,
|
|
479
518
|
});
|
|
480
519
|
} catch (err) {
|
|
481
|
-
await logDelivery({
|
|
520
|
+
await logDelivery(run, {
|
|
482
521
|
tenantId,
|
|
483
522
|
notificationType,
|
|
484
523
|
channel: channel.name,
|
|
@@ -499,14 +538,15 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
499
538
|
tenantId: TenantId,
|
|
500
539
|
priority: NotifyPriority,
|
|
501
540
|
recipientId: string | null,
|
|
541
|
+
run: NotifyRun,
|
|
502
542
|
): Promise<void> {
|
|
503
|
-
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId);
|
|
543
|
+
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
504
544
|
|
|
505
545
|
// Direct routing skips preferences (no user account) but NOT rate limit
|
|
506
546
|
// — direct sends can still be abused (webhook replays, test harnesses).
|
|
507
547
|
for (const channel of channels) {
|
|
508
548
|
const address = route[channel.name];
|
|
509
|
-
const message = buildMessage(notificationType, data, channel.name);
|
|
549
|
+
const message = buildMessage(notificationType, data, channel.name, run.locale);
|
|
510
550
|
if (!address) continue;
|
|
511
551
|
|
|
512
552
|
// Address opt-out (critical priority skips it, same rule as user
|
|
@@ -515,7 +555,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
515
555
|
priority !== "critical" &&
|
|
516
556
|
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
517
557
|
) {
|
|
518
|
-
await logDelivery({
|
|
558
|
+
await logDelivery(run, {
|
|
519
559
|
tenantId,
|
|
520
560
|
notificationType,
|
|
521
561
|
channel: channel.name,
|
|
@@ -532,7 +572,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
532
572
|
if (rateLimit) {
|
|
533
573
|
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
534
574
|
if (!allowed) {
|
|
535
|
-
await logDelivery({
|
|
575
|
+
await logDelivery(run, {
|
|
536
576
|
tenantId,
|
|
537
577
|
notificationType,
|
|
538
578
|
channel: channel.name,
|
|
@@ -548,6 +588,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
548
588
|
|
|
549
589
|
try {
|
|
550
590
|
await deliverViaChannel({
|
|
591
|
+
run,
|
|
551
592
|
channel,
|
|
552
593
|
address,
|
|
553
594
|
message,
|
|
@@ -558,7 +599,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
558
599
|
priority,
|
|
559
600
|
});
|
|
560
601
|
} catch (err) {
|
|
561
|
-
await logDelivery({
|
|
602
|
+
await logDelivery(run, {
|
|
562
603
|
tenantId,
|
|
563
604
|
notificationType,
|
|
564
605
|
channel: channel.name,
|
|
@@ -573,14 +614,19 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
573
614
|
}
|
|
574
615
|
|
|
575
616
|
return {
|
|
576
|
-
async notify(notificationType, options, _user, tenantId) {
|
|
617
|
+
async notify(notificationType, options, _user, tenantId, jobDispatcher) {
|
|
577
618
|
const { to, route, data, idempotencyKey } = options;
|
|
578
619
|
const priority: NotifyPriority = options.priority ?? "normal";
|
|
620
|
+
const run: NotifyRun = {
|
|
621
|
+
deliveries: [],
|
|
622
|
+
jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
|
|
623
|
+
locale: options.locale,
|
|
624
|
+
};
|
|
579
625
|
|
|
580
626
|
if (idempotencyKey) {
|
|
581
627
|
const first = await claimIdempotency(tenantId, idempotencyKey);
|
|
582
628
|
if (!first) {
|
|
583
|
-
await logDelivery({
|
|
629
|
+
await logDelivery(run, {
|
|
584
630
|
tenantId,
|
|
585
631
|
notificationType,
|
|
586
632
|
channel: "*",
|
|
@@ -590,8 +636,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
590
636
|
error: "duplicate_idempotency_key",
|
|
591
637
|
priority,
|
|
592
638
|
});
|
|
593
|
-
|
|
594
|
-
return;
|
|
639
|
+
return { deliveries: run.deliveries };
|
|
595
640
|
}
|
|
596
641
|
}
|
|
597
642
|
|
|
@@ -603,9 +648,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
603
648
|
tenantId,
|
|
604
649
|
priority,
|
|
605
650
|
options.recipientId ?? null,
|
|
651
|
+
run,
|
|
606
652
|
);
|
|
607
|
-
|
|
608
|
-
return;
|
|
653
|
+
return { deliveries: run.deliveries };
|
|
609
654
|
}
|
|
610
655
|
|
|
611
656
|
if (to !== undefined) {
|
|
@@ -620,9 +665,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
620
665
|
}
|
|
621
666
|
|
|
622
667
|
for (const userId of userIds) {
|
|
623
|
-
await deliverToUser(userId, notificationType, data, tenantId, priority);
|
|
668
|
+
await deliverToUser(userId, notificationType, data, tenantId, priority, run);
|
|
624
669
|
}
|
|
625
670
|
}
|
|
671
|
+
return { deliveries: run.deliveries };
|
|
626
672
|
},
|
|
627
673
|
};
|
|
628
674
|
}
|
|
@@ -3,13 +3,17 @@ export {
|
|
|
3
3
|
type ChatWebhookChannelOptions,
|
|
4
4
|
chatMessageText,
|
|
5
5
|
createChatWebhookChannel,
|
|
6
|
+
resolveChatWebhookTarget,
|
|
6
7
|
toChannelResult,
|
|
7
8
|
} from "./chat-webhook-channel.js";
|
|
8
9
|
export {
|
|
9
10
|
type ChatSendFailureCode,
|
|
10
11
|
type ChatSendResult,
|
|
11
12
|
type ChatWebhookRequest,
|
|
13
|
+
type ChatWebhookTarget,
|
|
12
14
|
chatConnectionNameSchema,
|
|
15
|
+
chatWebhookUrlSchema,
|
|
16
|
+
checkChatWebhookTarget,
|
|
13
17
|
DEFAULT_CHAT_TIMEOUT_MS,
|
|
14
18
|
postChatWebhook,
|
|
15
19
|
truncateChars,
|
|
@@ -31,6 +31,7 @@ const channelMessageSchema = z.object({
|
|
|
31
31
|
title: z.string(),
|
|
32
32
|
body: z.string().optional(),
|
|
33
33
|
data: z.record(z.string(), z.unknown()).optional(),
|
|
34
|
+
locale: z.string().optional(),
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
const renderJobPayloadSchema = z.object({
|
|
@@ -82,6 +83,7 @@ function toMessage(p: RenderJobPayload): ChannelMessage {
|
|
|
82
83
|
title: p.message.title,
|
|
83
84
|
body: p.message.body,
|
|
84
85
|
data: p.message.data,
|
|
86
|
+
locale: p.message.locale,
|
|
85
87
|
};
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -115,7 +117,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
115
117
|
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
116
118
|
const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
|
|
117
119
|
const channel = resolveChannel(registry, p.channelName);
|
|
118
|
-
const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
|
|
120
|
+
const channelCtx = buildChannelContext(db, registry, undefined, tenantId, ctx.secrets);
|
|
119
121
|
|
|
120
122
|
try {
|
|
121
123
|
if (!channel.render) {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type {
|
|
4
|
+
NotifyDeliveryStatus,
|
|
5
|
+
NotifyJobDispatcher,
|
|
4
6
|
NotifyOptions,
|
|
5
7
|
NotifyPriority,
|
|
8
|
+
NotifyResult,
|
|
6
9
|
Registry,
|
|
7
10
|
SessionUser,
|
|
8
11
|
TenantId,
|
|
@@ -17,8 +20,8 @@ export type ChannelContext = {
|
|
|
17
20
|
readonly registry: Registry;
|
|
18
21
|
readonly sseBroker: SseBroker | undefined;
|
|
19
22
|
readonly tenantId: TenantId;
|
|
20
|
-
//
|
|
21
|
-
//
|
|
23
|
+
// Chat channels read their per-tenant webhook URL / bot token from it.
|
|
24
|
+
// Absent when no secrets feature/KEK is wired.
|
|
22
25
|
readonly secrets?: SecretsContext | undefined;
|
|
23
26
|
};
|
|
24
27
|
|
|
@@ -27,6 +30,7 @@ export type ChannelMessage = {
|
|
|
27
30
|
readonly title: string;
|
|
28
31
|
readonly body: string | undefined;
|
|
29
32
|
readonly data: Readonly<Record<string, unknown>> | undefined;
|
|
33
|
+
readonly locale?: string | undefined;
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
export type ChannelResult = {
|
|
@@ -70,6 +74,7 @@ export type DeliveryChannel = {
|
|
|
70
74
|
export type RendererInput = {
|
|
71
75
|
readonly template: string;
|
|
72
76
|
readonly variables: Readonly<Record<string, unknown>>;
|
|
77
|
+
readonly locale?: string | undefined;
|
|
73
78
|
};
|
|
74
79
|
|
|
75
80
|
export type NotificationRenderer = {
|
|
@@ -85,7 +90,7 @@ export type DeliveryLogEntry = {
|
|
|
85
90
|
readonly channel: string;
|
|
86
91
|
readonly recipientId: string | null;
|
|
87
92
|
readonly recipientAddress: string | null;
|
|
88
|
-
readonly status:
|
|
93
|
+
readonly status: NotifyDeliveryStatus;
|
|
89
94
|
readonly error: string | null;
|
|
90
95
|
readonly priority: NotifyPriority;
|
|
91
96
|
};
|
|
@@ -98,7 +103,8 @@ export type DeliveryService = {
|
|
|
98
103
|
options: NotifyOptions,
|
|
99
104
|
user: SessionUser,
|
|
100
105
|
tenantId: TenantId,
|
|
101
|
-
|
|
106
|
+
jobDispatcher?: NotifyJobDispatcher,
|
|
107
|
+
): Promise<NotifyResult>;
|
|
102
108
|
};
|
|
103
109
|
|
|
104
110
|
// r.useExtension options-shape: `name` is NOT part of the registration
|