@cosmicdrift/kumiko-samples 0.365.1 → 0.367.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 +1 -1
- package/packages/bundled-features/src/admin-shell/feature.ts +9 -9
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +87 -5
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +0 -2
- package/packages/bundled-features/src/auth-email-password/feature.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +4 -4
- package/packages/bundled-features/src/auth-mfa-user-data/index.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +89 -28
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/constants.ts +3 -0
- package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
- package/packages/bundled-features/src/cap-counter/tables.ts +5 -2
- package/packages/bundled-features/src/cap-overview/feature.ts +3 -3
- package/packages/bundled-features/src/channel-discord/feature.ts +2 -2
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
- package/packages/bundled-features/src/channel-email/feature.ts +2 -2
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-push/feature.ts +1 -1
- package/packages/bundled-features/src/channel-slack/feature.ts +2 -2
- package/packages/bundled-features/src/channel-teams/feature.ts +2 -2
- package/packages/bundled-features/src/channel-telegram/feature.ts +2 -2
- package/packages/bundled-features/src/compliance-profiles/feature.ts +1 -1
- package/packages/bundled-features/src/compliance-profiles-ops/index.ts +3 -3
- package/packages/bundled-features/src/config/feature.ts +1 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/feature.ts +2 -2
- package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +17 -13
- package/packages/bundled-features/src/data-retention/feature.ts +1 -1
- package/packages/bundled-features/src/data-retention/presets.ts +4 -4
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +6 -2
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +4 -4
- package/packages/bundled-features/src/delivery/delivery-service.ts +71 -4
- package/packages/bundled-features/src/delivery/feature.ts +10 -6
- package/packages/bundled-features/src/delivery/handlers/resubscribe-address.write.ts +12 -8
- package/packages/bundled-features/src/delivery/handlers/resubscribe-user.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/set-preference.write.ts +3 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +13 -6
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-user.write.ts +3 -1
- package/packages/bundled-features/src/delivery/index.ts +6 -0
- package/packages/bundled-features/src/delivery/notification-type-aliases.ts +13 -0
- package/packages/bundled-features/src/delivery/testing.ts +2 -0
- package/packages/bundled-features/src/delivery/types.ts +4 -0
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
- package/packages/bundled-features/src/derivatives-sharp/feature.ts +1 -1
- package/packages/bundled-features/src/document-ingest-foundation/feature.ts +2 -2
- package/packages/bundled-features/src/file-derivatives/feature.ts +3 -3
- package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/file-provider-inmemory/feature.ts +2 -2
- package/packages/bundled-features/src/file-provider-s3/feature.ts +3 -3
- package/packages/bundled-features/src/file-provider-s3-env/feature.ts +2 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
- package/packages/bundled-features/src/folders/feature.ts +2 -2
- package/packages/bundled-features/src/folders-user-data/index.ts +1 -1
- package/packages/bundled-features/src/form-draft/feature.ts +2 -2
- package/packages/bundled-features/src/form-draft-user-data/index.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +9 -1
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +19 -7
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +21 -2
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -2
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +1 -1
- package/packages/bundled-features/src/ledger/feature.ts +5 -5
- package/packages/bundled-features/src/legal-pages/feature.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/feature.ts +51 -10
- package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
- package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +2 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +3 -3
- package/packages/bundled-features/src/managed-pages/css-gate.ts +1 -1
- package/packages/bundled-features/src/managed-pages/feature.ts +2 -2
- package/packages/bundled-features/src/metrics/feature.ts +2 -2
- package/packages/bundled-features/src/notes-history/feature.ts +2 -2
- package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +15 -11
- package/packages/bundled-features/src/readiness/feature.ts +2 -2
- package/packages/bundled-features/src/renderer-foundation/README.md +1 -1
- package/packages/bundled-features/src/renderer-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/renderer-pdf/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/seo/feature.ts +1 -1
- package/packages/bundled-features/src/sessions/feature.ts +17 -13
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -1
- package/packages/bundled-features/src/subscription-mollie/feature.ts +1 -1
- package/packages/bundled-features/src/subscription-stripe/feature.ts +3 -3
- package/packages/bundled-features/src/tags/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +25 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
- package/packages/bundled-features/src/template-resolver/index.ts +4 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
- package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
- package/packages/bundled-features/src/template-resolver-user-data/index.ts +1 -1
- package/packages/bundled-features/src/tenant/feature.ts +4 -4
- package/packages/bundled-features/src/tenant/seeding.ts +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +2 -3
- package/packages/bundled-features/src/tenant-settings/feature.ts +1 -1
- package/packages/bundled-features/src/tier-engine/feature.ts +36 -27
- package/packages/bundled-features/src/user/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/README.md +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -4
- package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +2 -2
- package/packages/bundled-features/src/user-profile/feature.ts +4 -4
- package/packages/bundled-features/src/waitlist/feature.ts +2 -2
- package/samples/apps/cap-billing-demo/README.md +1 -1
- package/samples/apps/cap-billing-demo/src/feature.ts +4 -4
- package/samples/apps/cap-billing-demo/src/run-config.ts +1 -1
- package/samples/apps/config-demo/src/features/demo/feature.ts +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/feature.ts +43 -28
- package/samples/apps/styleguide/src/features/demo/feature.ts +18 -18
- package/samples/apps/styleguide/src/features/widgets/ai-text-demo.ts +10 -12
- package/samples/apps/styleguide/src/features/widgets/feature.ts +90 -108
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +36 -36
- package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +6 -6
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +7 -7
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +3 -3
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +1 -1
- package/samples/apps/user-data-rights-demo/src/feature.ts +5 -5
- package/samples/recipes/access-control/src/feature.ts +21 -25
- package/samples/recipes/anonymous-access/src/feature.ts +36 -43
- package/samples/recipes/anonymous-access-multitenant/src/feature.ts +14 -16
- package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
- package/samples/recipes/basic-entity/README.md +1 -1
- package/samples/recipes/boot-check/README.md +1 -1
- package/samples/recipes/boot-check/src/feature.ts +2 -2
- package/samples/recipes/chat-channels/src/feature.ts +1 -1
- package/samples/recipes/cross-feature-events/src/feature.ts +7 -7
- package/samples/recipes/currencies-global/src/feature.ts +3 -3
- package/samples/recipes/currencies-per-tenant/src/feature.ts +2 -2
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/custom-fields-basic/src/feature.ts +3 -2
- package/samples/recipes/custom-handlers/README.md +3 -3
- package/samples/recipes/custom-handlers/src/feature.ts +37 -41
- package/samples/recipes/dashboard-stats/src/feature.ts +6 -6
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/delivery-notifications/src/feature.ts +8 -8
- package/samples/recipes/designer-demo/src/feature.ts +2 -1
- package/samples/recipes/embedded/src/feature.ts +1 -1
- package/samples/recipes/embedded-entity-form/src/feature.ts +13 -13
- package/samples/recipes/encrypted-tenant-config/src/feature.ts +1 -1
- package/samples/recipes/error-contract/src/feature.ts +25 -25
- package/samples/recipes/event-sourcing/src/feature.ts +70 -76
- package/samples/recipes/feature-toggles/src/feature.ts +28 -20
- package/samples/recipes/files-post-processing/src/feature.ts +22 -26
- package/samples/recipes/idempotency/src/feature.ts +16 -18
- package/samples/recipes/inbound-mail-triage/src/feature.ts +1 -1
- package/samples/recipes/lane-routing/src/feature.ts +10 -12
- package/samples/recipes/legal-pages/src/feature.ts +1 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -4
- package/samples/recipes/lifecycle-hooks/src/feature.ts +55 -35
- package/samples/recipes/managed-config/src/feature.ts +8 -8
- package/samples/recipes/managed-pages/src/feature.ts +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/notes-history-basic/src/feature.ts +2 -2
- package/samples/recipes/pdf-audit-export/src/feature.ts +7 -7
- package/samples/recipes/pipeline-basics/README.md +1 -1
- package/samples/recipes/pipeline-basics/src/feature.ts +7 -7
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/rate-limiting/src/feature.ts +7 -9
- package/samples/recipes/record-detail-layout/src/feature.ts +18 -18
- package/samples/recipes/reference-data/src/feature.ts +1 -1
- package/samples/recipes/relations/src/feature.ts +2 -2
- package/samples/recipes/screens-nav/src/feature.ts +1 -1
- package/samples/recipes/search/src/feature.ts +17 -19
- package/samples/recipes/security-baseline/README.md +1 -1
- package/samples/recipes/state-machine/src/feature.ts +1 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/tags-basic/src/feature.ts +2 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/samples/recipes/tenant-settings/src/feature.ts +1 -1
- package/samples/recipes/timezones/src/feature.ts +12 -12
- package/samples/recipes/user-data-rights/README.md +2 -2
- package/samples/recipes/user-data-rights/src/feature.ts +1 -1
- package/samples/recipes/user-profile/src/feature.ts +1 -1
- package/samples/recipes/webhook-step/src/feature.ts +1 -1
- package/samples/recipes/writeform-section/src/feature.ts +12 -12
|
@@ -164,7 +164,11 @@ async function resolveAttachments(
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
export type EmailChannelOptions = {
|
|
167
|
+
/** The app-wide transport, used for every tenant `tenantTransport` returns null for. */
|
|
167
168
|
readonly transport: EmailTransport;
|
|
169
|
+
/** Resolves the sending tenant's own transport, null when it has none. Built per send and
|
|
170
|
+
* never cached, so a provider change applies to the next mail; a throw fails the send. */
|
|
171
|
+
readonly tenantTransport?: (ctx: ChannelContext) => Promise<EmailTransport | null>;
|
|
168
172
|
readonly resolveEmail: (
|
|
169
173
|
userId: string,
|
|
170
174
|
ctx: { db: unknown; tenantId: TenantId },
|
|
@@ -172,7 +176,7 @@ export type EmailChannelOptions = {
|
|
|
172
176
|
};
|
|
173
177
|
|
|
174
178
|
export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
|
|
175
|
-
const { transport, resolveEmail } = options;
|
|
179
|
+
const { transport, resolveEmail, tenantTransport } = options;
|
|
176
180
|
|
|
177
181
|
// Render runs in the delivery.render job, decoupled from the SMTP send so
|
|
178
182
|
// each can retry independently. Extracted so the inline path (no job runner)
|
|
@@ -209,6 +213,11 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
async function transportFor(ctx: ChannelContext): Promise<EmailTransport> {
|
|
217
|
+
if (tenantTransport === undefined) return transport;
|
|
218
|
+
return (await tenantTransport(ctx)) ?? transport;
|
|
219
|
+
}
|
|
220
|
+
|
|
212
221
|
return {
|
|
213
222
|
name: EMAIL_CHANNEL_NAME,
|
|
214
223
|
mode: "queued",
|
|
@@ -251,7 +260,8 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
251
260
|
return { status: "failed", error: "send_failed", address };
|
|
252
261
|
}
|
|
253
262
|
const resolvedAttachments = resolution.attachments;
|
|
254
|
-
await
|
|
263
|
+
const sendTransport = await transportFor(ctx);
|
|
264
|
+
await sendTransport.send(
|
|
255
265
|
guardEmailMessage({
|
|
256
266
|
to: address,
|
|
257
267
|
subject,
|
|
@@ -10,14 +10,14 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
10
10
|
|
|
11
11
|
return defineFeature("channel-email", (r) => {
|
|
12
12
|
r.describe(
|
|
13
|
-
"Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.",
|
|
13
|
+
"Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
|
|
14
14
|
);
|
|
15
15
|
r.uiHints({
|
|
16
16
|
displayLabel: "Email Channel",
|
|
17
17
|
category: "notifications",
|
|
18
18
|
recommended: false,
|
|
19
19
|
});
|
|
20
|
-
r.requires("delivery");
|
|
20
|
+
r.requires({ features: ["delivery"] });
|
|
21
21
|
|
|
22
22
|
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
23
23
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
|
|
@@ -17,7 +17,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
|
|
|
17
17
|
category: "notifications",
|
|
18
18
|
recommended: false,
|
|
19
19
|
});
|
|
20
|
-
r.requires("delivery");
|
|
20
|
+
r.requires({ features: ["delivery"] });
|
|
21
21
|
|
|
22
22
|
r.storeTable(inAppMessagesTableMeta, {
|
|
23
23
|
reason: "read_side.in_app_messages",
|
|
@@ -14,7 +14,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
|
|
|
14
14
|
category: "notifications",
|
|
15
15
|
recommended: false,
|
|
16
16
|
});
|
|
17
|
-
r.requires("delivery");
|
|
17
|
+
r.requires({ features: ["delivery"] });
|
|
18
18
|
|
|
19
19
|
r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
|
|
20
20
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
@@ -21,8 +21,8 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
|
|
|
21
21
|
category: "notifications",
|
|
22
22
|
recommended: false,
|
|
23
23
|
});
|
|
24
|
-
r.requires("delivery");
|
|
25
|
-
r.requires("secrets");
|
|
24
|
+
r.requires({ features: ["delivery"] });
|
|
25
|
+
r.requires({ features: ["secrets"] });
|
|
26
26
|
|
|
27
27
|
const webhookTarget = resolveChatWebhookTarget(
|
|
28
28
|
{ defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS },
|
|
@@ -21,8 +21,8 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
|
|
|
21
21
|
category: "notifications",
|
|
22
22
|
recommended: false,
|
|
23
23
|
});
|
|
24
|
-
r.requires("delivery");
|
|
25
|
-
r.requires("secrets");
|
|
24
|
+
r.requires({ features: ["delivery"] });
|
|
25
|
+
r.requires({ features: ["secrets"] });
|
|
26
26
|
|
|
27
27
|
const webhookTarget = resolveChatWebhookTarget(
|
|
28
28
|
{ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS },
|
|
@@ -19,8 +19,8 @@ export function createChannelTelegramFeature(
|
|
|
19
19
|
category: "notifications",
|
|
20
20
|
recommended: false,
|
|
21
21
|
});
|
|
22
|
-
r.requires("delivery");
|
|
23
|
-
r.requires("secrets");
|
|
22
|
+
r.requires({ features: ["delivery"] });
|
|
23
|
+
r.requires({ features: ["secrets"] });
|
|
24
24
|
|
|
25
25
|
const botToken = r.secret("botToken", {
|
|
26
26
|
label: { en: "Telegram bot token" },
|
|
@@ -63,7 +63,7 @@ export function createComplianceProfilesFeature(
|
|
|
63
63
|
// Context, Profile-Selection ist eigene Entity, sub-processor-Liste
|
|
64
64
|
// sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
|
|
65
65
|
// r.requires hier rein.
|
|
66
|
-
r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
|
|
66
|
+
r.entity({ name: "tenant-compliance-profile", ...tenantComplianceProfileEntity });
|
|
67
67
|
|
|
68
68
|
r.exposesApi("compliance.forTenant");
|
|
69
69
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// a separate feature (mirrors folders-user-data/notes-history-user-data)
|
|
8
8
|
// rather than folded into compliance-profiles: this is the one genuinely
|
|
9
9
|
// cross-tenant capability in the picker's orbit, and it alone needs
|
|
10
|
-
// r.systemScope() + a hard r.requires("tenant") — apps that only want the
|
|
10
|
+
// r.systemScope() + a hard r.requires({ features: ["tenant"] }) — apps that only want the
|
|
11
11
|
// per-tenant picker (the vast majority) stay unaffected.
|
|
12
12
|
|
|
13
13
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -23,8 +23,8 @@ export const complianceProfilesOpsFeature = defineFeature("compliance-profiles-o
|
|
|
23
23
|
recommended: false,
|
|
24
24
|
});
|
|
25
25
|
r.systemScope();
|
|
26
|
-
r.requires("compliance-profiles");
|
|
27
|
-
r.requires("tenant");
|
|
26
|
+
r.requires({ features: ["compliance-profiles"] });
|
|
27
|
+
r.requires({ features: ["tenant"] });
|
|
28
28
|
|
|
29
29
|
const queries = {
|
|
30
30
|
tenantsMissingProfile: r.queryHandler(tenantsMissingProfileQuery),
|
|
@@ -45,7 +45,7 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
45
45
|
// projection write in one TX. Subscribers that need config-change
|
|
46
46
|
// semantics listen to those auto-events via r.multiStreamProjection
|
|
47
47
|
// (see docs/plans/architecture/event-sourcing-pivot.md §4.7).
|
|
48
|
-
r.entity("config-value", configValueEntity);
|
|
48
|
+
r.entity({ name: "config-value", ...configValueEntity });
|
|
49
49
|
|
|
50
50
|
const handlers = {
|
|
51
51
|
set: r.writeHandler(setWrite),
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.366.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject is covered for users stored before the KMS and users without an event stream",
|
|
6
|
+
"detail": "Tests now cover forget-subject for users stored as plaintext before the KMS was configured and for users without an event stream. Both succeed since the KMS tombstone fix, close the login door and write the audit event."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.365.1",
|
|
4
10
|
"type": "fix",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (crypto-shredding, event ciphertext, forget pipeline).
|
|
11
11
|
//
|
|
12
12
|
// **Was diese Feature liefert (B1 + B2, 2026-05-23):**
|
|
13
|
-
// 1. r.entity("field-definition") — Definition-Storage (event-sourced).
|
|
13
|
+
// 1. r.entity({ name: "field-definition" }) — Definition-Storage (event-sourced).
|
|
14
14
|
// 2. define-tenant-field / define-system-field — RBAC write-handlers für
|
|
15
15
|
// Definition-CRUD.
|
|
16
16
|
// 3. delete-tenant-field / delete-system-field — RBAC write-handlers.
|
|
@@ -123,7 +123,7 @@ function registerCustomFields(
|
|
|
123
123
|
category: "data",
|
|
124
124
|
recommended: false,
|
|
125
125
|
});
|
|
126
|
-
r.entity("field-definition", fieldDefinitionEntity);
|
|
126
|
+
r.entity({ name: "field-definition", ...fieldDefinitionEntity });
|
|
127
127
|
|
|
128
128
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
129
129
|
// Returned EventDefs liefern .name als compile-time literal-typed string,
|
|
@@ -44,8 +44,8 @@ export function customFieldsField(): JsonbFieldDef {
|
|
|
44
44
|
// Consumer-side:
|
|
45
45
|
//
|
|
46
46
|
// defineFeature("property-mgmt", (r) => {
|
|
47
|
-
// r.entity("property", propertyEntity); // muss customFieldsField() haben
|
|
48
|
-
// r.requires(CUSTOM_FIELDS_FEATURE_NAME);
|
|
47
|
+
// r.entity({ name: "property", ...propertyEntity }); // muss customFieldsField() haben
|
|
48
|
+
// r.requires({ features: [CUSTOM_FIELDS_FEATURE_NAME] });
|
|
49
49
|
// wireCustomFieldsFor(r, "property", propertyTable);
|
|
50
50
|
// });
|
|
51
51
|
//
|
|
@@ -198,18 +198,22 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
198
198
|
|
|
199
199
|
// postQuery-hook: flatten row.customFields jsonb auf root-level der
|
|
200
200
|
// API-response. Spec-Promise Z.4 "indistinguishable von Stammfeldern".
|
|
201
|
-
r.hook(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
201
|
+
r.hook({
|
|
202
|
+
type: "postQuery",
|
|
203
|
+
target: { allOf: entityName },
|
|
204
|
+
handler: async ({ rows }) => ({
|
|
205
|
+
rows: rows.map((row) => {
|
|
206
|
+
const customFields = row["customFields"];
|
|
207
|
+
if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
|
|
208
|
+
return {
|
|
209
|
+
...(customFields as Record<string, unknown>), // @cast-boundary db-row jsonb runtime-untyped
|
|
210
|
+
...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return row;
|
|
214
|
+
}),
|
|
211
215
|
}),
|
|
212
|
-
})
|
|
216
|
+
});
|
|
213
217
|
|
|
214
218
|
// Search-Payload-Extension: customFields-keys flach ins Index-Doc.
|
|
215
219
|
// Anders als postQuery-hook (der ALLE keys merged) trägt der Search-
|
|
@@ -65,7 +65,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
65
65
|
category: "compliance",
|
|
66
66
|
recommended: false,
|
|
67
67
|
});
|
|
68
|
-
r.entity("tenant-retention-override", tenantRetentionOverrideEntity);
|
|
68
|
+
r.entity({ name: "tenant-retention-override", ...tenantRetentionOverrideEntity });
|
|
69
69
|
|
|
70
70
|
// S2.D3: Cross-Feature-API fuer Forget-Flow + Cleanup-Job
|
|
71
71
|
r.exposesApi("retention.policyFor");
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
import type { RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Retention policy keyed by entity name, i.e. the `name` from
|
|
17
|
+
* r.entity({ name, ... }) (kebab-case or lowercase). The cleanup job walks
|
|
18
|
+
* all known entities; anything missing from the preset falls back to the
|
|
19
|
+
* entity default (layer 1).
|
|
20
20
|
*/
|
|
21
21
|
export type RetentionPreset = Readonly<Record<string, RetentionDef>>;
|
|
22
22
|
|
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.367.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Delivery signs unsubscribe links itself",
|
|
6
|
+
"detail": "r.notification(name, { unsubscribe: true }) makes delivery put a signed data.unsubscribeUrl into every email of that notification when the service has unsubscribeLinks. User recipients get a user token, route recipients an address token (only with a blind-index key, otherwise no link). The token is scoped to tenant, notification and channel; a link the caller passes in data.unsubscribeUrl wins. runProdApp option unsubscribe: { secret } mounts createUnsubscribeRoutes and wires the links; the base URL comes from auth.publicOrigin, auth.mail.baseUrl or APP_BASE_URL, and without one boot warns and no link is added."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.367.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Unsubscribe links use the tenant's host and the mail's language, apps learn about address opt-outs",
|
|
12
|
+
"detail": "unsubscribeLinks and the runProdApp option unsubscribe take tenantOrigin, an app callback that returns the sending tenant's own origin (subdomain or custom domain) per send; null, a throw or a non-https value fall back to the app origin and never fail the send. The signed token now carries the mail's locale (notify option locale), so the confirmation, result and undo pages render in that language; tokens without it keep working and show English. A form post to the resubscribe route now answers with an HTML page, JSON callers are unchanged. After a committed address opt-out an app job declared with trigger on DeliveryHandlers.unsubscribeAddress receives addressHash, notificationType and channel (unsubscribeAddressPayloadSchema) with the tenant in ctx.triggeredBy.tenantId; hashUnsubscribeAddress(email) matches it to the app's own subscriber. UNSUBSCRIBE_PAGE_EN exports the English page texts, German and Spanish come with the locale packages."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.366.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Renaming a notification type keeps user preferences and address opt-outs",
|
|
18
|
+
"detail": "r.notification accepts renamedFrom, a list of former qualified names. Preferences and address opt-outs stored under a former name keep applying to the renamed notification, and a stored choice under the current name wins over one under a former name. New writes via setPreference and the unsubscribe and resubscribe handlers are stored under the current name, resubscribeAddress also lifts opt-outs under former names. Boot fails if an alias is a current notification name, is the wildcard, or is claimed by two notifications."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.365.0",
|
|
4
22
|
"type": "fix",
|
|
@@ -153,6 +153,7 @@ export function buildChannelContext(
|
|
|
153
153
|
sseBroker,
|
|
154
154
|
tenantId,
|
|
155
155
|
secrets,
|
|
156
|
+
config: tenantConfig,
|
|
156
157
|
renderMail: createTemplateMailRenderer(db, registry, tenantId, tenantConfig),
|
|
157
158
|
...(filesForTenant !== undefined && {
|
|
158
159
|
readAttachment: createEmailAttachmentReader(
|
|
@@ -16,14 +16,18 @@ export function addressOptOutKey(addressHash: string, channel: string): AddressO
|
|
|
16
16
|
export async function selectOptedOutAddresses(
|
|
17
17
|
db: DbConnection,
|
|
18
18
|
tenantId: TenantId,
|
|
19
|
-
|
|
19
|
+
notificationTypeNames: readonly string[],
|
|
20
20
|
addressHashes: readonly string[],
|
|
21
21
|
): Promise<ReadonlySet<AddressOptOutKey>> {
|
|
22
22
|
if (addressHashes.length === 0) return new Set();
|
|
23
23
|
const rows = await selectMany<{ readonly addressHash: string; readonly channel: string }>(
|
|
24
24
|
db,
|
|
25
25
|
notificationAddressOptOutsTable,
|
|
26
|
-
{
|
|
26
|
+
{
|
|
27
|
+
tenantId,
|
|
28
|
+
notificationType: { in: notificationTypeNames },
|
|
29
|
+
addressHash: { in: addressHashes },
|
|
30
|
+
},
|
|
27
31
|
);
|
|
28
32
|
return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
|
|
29
33
|
}
|
|
@@ -13,7 +13,7 @@ export async function selectNotificationPreferences(
|
|
|
13
13
|
db: DbConnection,
|
|
14
14
|
tenantId: TenantId,
|
|
15
15
|
userId: string,
|
|
16
|
-
|
|
16
|
+
notificationTypeNames: readonly string[],
|
|
17
17
|
channelName: string,
|
|
18
18
|
): Promise<readonly NotificationPreferenceRow[]> {
|
|
19
19
|
return unsafeReadRetrying<NotificationPreferenceRow>(
|
|
@@ -23,10 +23,10 @@ export async function selectNotificationPreferences(
|
|
|
23
23
|
WHERE tenant_id = $1
|
|
24
24
|
AND user_id = $2
|
|
25
25
|
AND (
|
|
26
|
-
(notification_type = $3 AND channel = $4)
|
|
26
|
+
(notification_type = ANY($3::text[]) AND channel = $4)
|
|
27
27
|
OR (notification_type = '*' AND channel = $4)
|
|
28
|
-
OR (notification_type = $3 AND channel = '*')
|
|
28
|
+
OR (notification_type = ANY($3::text[]) AND channel = '*')
|
|
29
29
|
)`,
|
|
30
|
-
[tenantId, userId,
|
|
30
|
+
[tenantId, userId, notificationTypeNames, channelName],
|
|
31
31
|
);
|
|
32
32
|
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
DeliveryJobs,
|
|
38
38
|
deliveryIdempotencyRedisKey,
|
|
39
39
|
deliveryPriorityRank,
|
|
40
|
+
EMAIL_CHANNEL_NAME,
|
|
40
41
|
} from "./constants.js";
|
|
41
42
|
import {
|
|
42
43
|
type AddressOptOutKey,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
selectOptedOutAddresses,
|
|
45
46
|
} from "./db/queries/address-opt-outs.js";
|
|
46
47
|
import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
48
|
+
import { notificationTypeNames } from "./notification-type-aliases.js";
|
|
47
49
|
import {
|
|
48
50
|
type ChannelContext,
|
|
49
51
|
type ChannelMessage,
|
|
@@ -54,6 +56,12 @@ import {
|
|
|
54
56
|
isDeliveryChannelPlugin,
|
|
55
57
|
type RenderedMessage,
|
|
56
58
|
} from "./types.js";
|
|
59
|
+
import {
|
|
60
|
+
buildUnsubscribeUrl,
|
|
61
|
+
type UnsubscribeLinkConfig,
|
|
62
|
+
type UnsubscribeLinkRecipient,
|
|
63
|
+
unsubscribeLinkOrigin,
|
|
64
|
+
} from "./unsubscribe-link.js";
|
|
57
65
|
|
|
58
66
|
export function redactedMessageOf(err: unknown): string {
|
|
59
67
|
return redactErrorText(err instanceof Error ? err.message : String(err));
|
|
@@ -96,6 +104,9 @@ export type DeliveryServiceOptions = {
|
|
|
96
104
|
// absent means an unattributed warn log (see fallbackEscapeHatchReporter).
|
|
97
105
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
98
106
|
readonly log?: Logger;
|
|
107
|
+
// Signs `data.unsubscribeUrl` for email notifications declared with `unsubscribe: true`.
|
|
108
|
+
// The secret must be the one `createUnsubscribeRoutes` verifies with.
|
|
109
|
+
readonly unsubscribeLinks?: UnsubscribeLinkConfig;
|
|
99
110
|
};
|
|
100
111
|
|
|
101
112
|
// Build channel list from registry extension usages
|
|
@@ -127,6 +138,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
127
138
|
tenantConfigFor,
|
|
128
139
|
escapeHatchAuditSink,
|
|
129
140
|
log,
|
|
141
|
+
unsubscribeLinks,
|
|
130
142
|
} = options;
|
|
131
143
|
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
132
144
|
const logError = createFallbackLogger("delivery", log);
|
|
@@ -453,6 +465,37 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
453
465
|
}
|
|
454
466
|
}
|
|
455
467
|
|
|
468
|
+
// A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
|
|
469
|
+
// context, never from the notification data.
|
|
470
|
+
async function withUnsubscribeUrl(
|
|
471
|
+
message: ChannelMessage,
|
|
472
|
+
channelName: string,
|
|
473
|
+
recipient: UnsubscribeLinkRecipient,
|
|
474
|
+
channelCtx: ChannelContext,
|
|
475
|
+
): Promise<ChannelMessage> {
|
|
476
|
+
if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME) return message;
|
|
477
|
+
if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
|
|
478
|
+
return message;
|
|
479
|
+
}
|
|
480
|
+
const supplied = message.data?.["unsubscribeUrl"];
|
|
481
|
+
if (typeof supplied === "string" && supplied !== "") return message;
|
|
482
|
+
const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) =>
|
|
483
|
+
logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
|
|
484
|
+
tenantId: channelCtx.tenantId,
|
|
485
|
+
notificationType: message.notificationType,
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
|
|
489
|
+
tenantId: channelCtx.tenantId,
|
|
490
|
+
notificationType: message.notificationType,
|
|
491
|
+
channel: channelName,
|
|
492
|
+
locale: message.locale,
|
|
493
|
+
});
|
|
494
|
+
return unsubscribeUrl === undefined
|
|
495
|
+
? message
|
|
496
|
+
: { ...message, data: { ...message.data, unsubscribeUrl } };
|
|
497
|
+
}
|
|
498
|
+
|
|
456
499
|
function buildMessage(
|
|
457
500
|
notificationType: string,
|
|
458
501
|
data: Readonly<Record<string, unknown>> | undefined,
|
|
@@ -503,7 +546,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
503
546
|
const addressHash = hashUnsubscribeAddress(address);
|
|
504
547
|
if (addressHash !== undefined) addressHashes.add(addressHash);
|
|
505
548
|
}
|
|
506
|
-
return selectOptedOutAddresses(
|
|
549
|
+
return selectOptedOutAddresses(
|
|
550
|
+
run.db,
|
|
551
|
+
tenantId,
|
|
552
|
+
notificationTypeNames(registry, notificationType),
|
|
553
|
+
[...addressHashes],
|
|
554
|
+
);
|
|
507
555
|
}
|
|
508
556
|
|
|
509
557
|
function isAddressSuppressed(
|
|
@@ -617,7 +665,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
617
665
|
run.db,
|
|
618
666
|
tenantId,
|
|
619
667
|
userId,
|
|
620
|
-
notificationType,
|
|
668
|
+
notificationTypeNames(registry, notificationType),
|
|
621
669
|
channelName,
|
|
622
670
|
);
|
|
623
671
|
|
|
@@ -629,6 +677,15 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
629
677
|
);
|
|
630
678
|
if (exact) return exact.enabled;
|
|
631
679
|
|
|
680
|
+
// Rows stored under a former name: the user's choice from before the rename
|
|
681
|
+
const aliasExact = prefs.filter(
|
|
682
|
+
(p) =>
|
|
683
|
+
p.notificationType !== "*" &&
|
|
684
|
+
p.notificationType !== notificationType &&
|
|
685
|
+
p.channel === channelName,
|
|
686
|
+
);
|
|
687
|
+
if (aliasExact.length > 0) return !aliasExact.some((p) => p.enabled === false);
|
|
688
|
+
|
|
632
689
|
// Only wildcards matched: any disabled entry disables delivery (deterministic
|
|
633
690
|
// and conservative — DB ordering no longer decides the outcome).
|
|
634
691
|
return !prefs.some((p) => p.enabled === false);
|
|
@@ -729,7 +786,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
729
786
|
run,
|
|
730
787
|
channel,
|
|
731
788
|
address,
|
|
732
|
-
message
|
|
789
|
+
message: await withUnsubscribeUrl(
|
|
790
|
+
message,
|
|
791
|
+
channel.name,
|
|
792
|
+
{ kind: "user", userId },
|
|
793
|
+
channelCtx,
|
|
794
|
+
),
|
|
733
795
|
channelCtx,
|
|
734
796
|
tenantId,
|
|
735
797
|
recipientId: userId,
|
|
@@ -827,7 +889,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
827
889
|
run,
|
|
828
890
|
channel,
|
|
829
891
|
address,
|
|
830
|
-
message
|
|
892
|
+
message: await withUnsubscribeUrl(
|
|
893
|
+
message,
|
|
894
|
+
channel.name,
|
|
895
|
+
{ kind: "address", address },
|
|
896
|
+
channelCtx,
|
|
897
|
+
),
|
|
831
898
|
channelCtx,
|
|
832
899
|
tenantId,
|
|
833
900
|
recipientId,
|
|
@@ -61,7 +61,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
61
61
|
const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
|
|
62
62
|
return defineFeature("delivery", (r) => {
|
|
63
63
|
r.describe(
|
|
64
|
-
|
|
64
|
+
"The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
|
|
65
65
|
);
|
|
66
66
|
r.uiHints({
|
|
67
67
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
@@ -70,18 +70,22 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
70
70
|
});
|
|
71
71
|
r.systemScope();
|
|
72
72
|
// delivery-log's tenantId column has refEntity: "tenant:tenant" (boot-checked).
|
|
73
|
-
r.requires("tenant");
|
|
73
|
+
r.requires({ features: ["tenant"] });
|
|
74
74
|
// Backing table: the (tenant,user,type,channel) uniqueIndex lives only on
|
|
75
75
|
// the physical table, not on the entity fields, so the generator would
|
|
76
76
|
// otherwise omit it → duplicate preference rows on concurrent upserts.
|
|
77
|
-
r.entity(
|
|
78
|
-
|
|
77
|
+
r.entity({
|
|
78
|
+
name: "notification-preference",
|
|
79
|
+
...notificationPreferenceEntity,
|
|
80
|
+
backingTable: notificationPreferencesTable,
|
|
79
81
|
});
|
|
80
82
|
// Same ride-along reason as notification-preference above: the
|
|
81
83
|
// (tenant,addressHash,type,channel) uniqueIndex lives only on the
|
|
82
84
|
// physical table.
|
|
83
|
-
r.entity(
|
|
84
|
-
|
|
85
|
+
r.entity({
|
|
86
|
+
name: "notification-address-opt-out",
|
|
87
|
+
...notificationAddressOptOutEntity,
|
|
88
|
+
backingTable: notificationAddressOptOutsTable,
|
|
85
89
|
});
|
|
86
90
|
r.storeTable(deliveryAttemptsTableMeta, {
|
|
87
91
|
reason: "read_side.delivery_attempt_log",
|
|
@@ -2,6 +2,7 @@ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine
|
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { removeAddressOptOut } from "../address-opt-out.js";
|
|
5
|
+
import { currentNotificationType, notificationTypeNames } from "../notification-type-aliases.js";
|
|
5
6
|
|
|
6
7
|
export const resubscribeAddressWrite = defineWriteHandler({
|
|
7
8
|
name: "resubscribeAddress",
|
|
@@ -20,7 +21,8 @@ export const resubscribeAddressWrite = defineWriteHandler({
|
|
|
20
21
|
description:
|
|
21
22
|
"Removes a no-account address opt-out for one notificationType/channel combination; dispatched by the signed resubscribe route, not meant for UI callers.",
|
|
22
23
|
handler: async (event, ctx) => {
|
|
23
|
-
const { addressHash,
|
|
24
|
+
const { addressHash, channel } = event.payload;
|
|
25
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
24
26
|
const { tenantId } = event.user;
|
|
25
27
|
|
|
26
28
|
if (!ctx.systemDb) {
|
|
@@ -30,13 +32,15 @@ export const resubscribeAddressWrite = defineWriteHandler({
|
|
|
30
32
|
}
|
|
31
33
|
const db = ctx.systemDb.assertTenantMatch(tenantId);
|
|
32
34
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
for (const name of notificationTypeNames(ctx.registry, notificationType)) {
|
|
36
|
+
const result = await removeAddressOptOut(db, event.user, {
|
|
37
|
+
tenantId,
|
|
38
|
+
addressHash,
|
|
39
|
+
notificationType: name,
|
|
40
|
+
channel,
|
|
41
|
+
});
|
|
42
|
+
if (!result.isSuccess) return result;
|
|
43
|
+
}
|
|
40
44
|
return { isSuccess: true, data: { notificationType, channel } };
|
|
41
45
|
},
|
|
42
46
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
4
5
|
import { upsertPreference } from "../upsert-preference.js";
|
|
5
6
|
|
|
6
7
|
export const resubscribeUserWrite = defineWriteHandler({
|
|
@@ -18,7 +19,8 @@ export const resubscribeUserWrite = defineWriteHandler({
|
|
|
18
19
|
description:
|
|
19
20
|
"Re-enables one notification type and channel combination for the userId carried in a verified unsubscribe token; dispatched by the signed resubscribe route, not meant for UI callers.",
|
|
20
21
|
handler: async (event, ctx) => {
|
|
21
|
-
const { userId,
|
|
22
|
+
const { userId, channel } = event.payload;
|
|
23
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
22
24
|
const { tenantId } = event.user;
|
|
23
25
|
|
|
24
26
|
if (!ctx.systemDb) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
4
5
|
import { upsertPreference } from "../upsert-preference.js";
|
|
5
6
|
|
|
6
7
|
export const setPreferenceWrite = defineWriteHandler({
|
|
@@ -21,7 +22,8 @@ export const setPreferenceWrite = defineWriteHandler({
|
|
|
21
22
|
description:
|
|
22
23
|
"Enables or disables one notification type and channel combination for the calling user, where either side may be the wildcard * as a catch-all; use it when a user changes their own notification settings.",
|
|
23
24
|
handler: async (event, ctx) => {
|
|
24
|
-
const {
|
|
25
|
+
const { channel, enabled } = event.payload;
|
|
26
|
+
const notificationType = currentNotificationType(ctx.registry, event.payload.notificationType);
|
|
25
27
|
const { id: userId, tenantId } = event.user;
|
|
26
28
|
|
|
27
29
|
// delivery runs in system-scope (feature.ts r.systemScope()) — ctx.systemDb
|