@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const DELIVERY_FEATURE = "delivery" as const;
|
|
3
|
+
|
|
4
|
+
// Qualified write handler names (QN format: scope:type:name)
|
|
5
|
+
export const DeliveryHandlers = {
|
|
6
|
+
setPreference: "delivery:write:set-preference",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
// Qualified query handler names (QN format: scope:type:name)
|
|
10
|
+
export const DeliveryQueries = {
|
|
11
|
+
log: "delivery:query:log",
|
|
12
|
+
preferences: "delivery:query:preferences",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
// Error codes
|
|
16
|
+
export const DeliveryErrors = {
|
|
17
|
+
noRecipient: "delivery_no_recipient",
|
|
18
|
+
channelFailed: "delivery_channel_failed",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
// Delivery status values
|
|
22
|
+
export const DeliveryStatus = {
|
|
23
|
+
sent: "sent",
|
|
24
|
+
failed: "failed",
|
|
25
|
+
skipped: "skipped",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type DeliveryStatusValue = (typeof DeliveryStatus)[keyof typeof DeliveryStatus];
|
|
29
|
+
|
|
30
|
+
// Qualified domain-event name. Emitted by the delivery-service on every
|
|
31
|
+
// attempt (sent / failed / skipped). A multi-stream-projection materialises
|
|
32
|
+
// each event into a row in deliveryAttemptsTable for the log-query handler.
|
|
33
|
+
export const DELIVERY_ATTEMPT_EVENT = "delivery:event:attempt" as const;
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import type { DbConnection, DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { NotifyPriority, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
7
|
+
import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
8
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
9
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
+
import { and, eq, or } from "drizzle-orm";
|
|
11
|
+
import type { Redis } from "ioredis";
|
|
12
|
+
import { DELIVERY_ATTEMPT_EVENT } from "./constants";
|
|
13
|
+
import { deliveryAttemptSchema } from "./events";
|
|
14
|
+
import { notificationPreferencesTable } from "./tables";
|
|
15
|
+
import type {
|
|
16
|
+
ChannelContext,
|
|
17
|
+
ChannelMessage,
|
|
18
|
+
DeliveryChannel,
|
|
19
|
+
DeliveryLogEntry,
|
|
20
|
+
DeliveryService,
|
|
21
|
+
NotificationRenderer,
|
|
22
|
+
} from "./types";
|
|
23
|
+
|
|
24
|
+
export type RateLimitConfig = {
|
|
25
|
+
readonly redis: Redis;
|
|
26
|
+
readonly maxPerHour: number; // per channel per tenant
|
|
27
|
+
readonly keyPrefix?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type KillSwitchResolver = (tenantId: TenantId, channelName: string) => Promise<boolean>;
|
|
31
|
+
|
|
32
|
+
export type DeliveryServiceOptions = {
|
|
33
|
+
readonly db: DbConnection;
|
|
34
|
+
readonly registry: Registry;
|
|
35
|
+
readonly sseBroker?: SseBroker;
|
|
36
|
+
readonly channels: readonly DeliveryChannel[];
|
|
37
|
+
readonly tenantUserIdsQuery?: string;
|
|
38
|
+
readonly rateLimit?: RateLimitConfig;
|
|
39
|
+
readonly isChannelKilled?: KillSwitchResolver; // returns true if channel is disabled for tenant
|
|
40
|
+
// Redis handle used for idempotencyKey dedup. Falls back to rateLimit.redis.
|
|
41
|
+
// Must be present whenever callers rely on idempotencyKey, otherwise notify()
|
|
42
|
+
// throws at the callsite (silent no-op would be a correctness bug).
|
|
43
|
+
readonly idempotencyRedis?: Redis;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Build channel list from registry extension usages
|
|
47
|
+
export function collectChannels(registry: Registry): DeliveryChannel[] {
|
|
48
|
+
const usages = registry.getExtensionUsages("deliveryChannel");
|
|
49
|
+
return usages.map((usage) => {
|
|
50
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
51
|
+
const opts = usage.options as {
|
|
52
|
+
resolve: DeliveryChannel["resolve"];
|
|
53
|
+
send: DeliveryChannel["send"];
|
|
54
|
+
};
|
|
55
|
+
return { name: usage.entityName, resolve: opts.resolve, send: opts.send };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Build renderer map from registry extension usages
|
|
60
|
+
export function collectRenderers(registry: Registry): Map<string, NotificationRenderer> {
|
|
61
|
+
const usages = registry.getExtensionUsages("notificationRenderer");
|
|
62
|
+
const map = new Map<string, NotificationRenderer>();
|
|
63
|
+
for (const usage of usages) {
|
|
64
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
65
|
+
const opts = usage.options as { render: NotificationRenderer["render"] };
|
|
66
|
+
map.set(usage.entityName, { name: usage.entityName, render: opts.render });
|
|
67
|
+
}
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createDeliveryService(options: DeliveryServiceOptions): DeliveryService {
|
|
72
|
+
const {
|
|
73
|
+
db,
|
|
74
|
+
registry,
|
|
75
|
+
sseBroker,
|
|
76
|
+
channels,
|
|
77
|
+
tenantUserIdsQuery,
|
|
78
|
+
rateLimit,
|
|
79
|
+
isChannelKilled,
|
|
80
|
+
idempotencyRedis,
|
|
81
|
+
} = options;
|
|
82
|
+
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
83
|
+
|
|
84
|
+
// Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
|
|
85
|
+
// Lua. Runs single-threaded in Redis, so two parallel clients can't both
|
|
86
|
+
// observe `count <= max` and slip past. The prior non-atomic JS version
|
|
87
|
+
// could leave the counter stuck below the true hit count when two INCRs
|
|
88
|
+
// raced into simultaneous DECR rollbacks.
|
|
89
|
+
const RATE_LIMIT_LUA = `
|
|
90
|
+
local count = redis.call('INCR', KEYS[1])
|
|
91
|
+
if count == 1 then
|
|
92
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
93
|
+
end
|
|
94
|
+
if count > tonumber(ARGV[2]) then
|
|
95
|
+
redis.call('DECR', KEYS[1])
|
|
96
|
+
return 0
|
|
97
|
+
end
|
|
98
|
+
return 1
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
type RedisWithLua = Redis & {
|
|
102
|
+
deliveryRateLimitCheck: (key: string, ttl: string, max: string) => Promise<number>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (rateLimit) {
|
|
106
|
+
// Register the Lua script once per Redis client. Noop if already defined.
|
|
107
|
+
// @cast-boundary engine-bridge — defineCommand attaches the Lua method post-boot
|
|
108
|
+
const r = rateLimit.redis as Partial<Pick<RedisWithLua, "deliveryRateLimitCheck">> & Redis;
|
|
109
|
+
if (!r.deliveryRateLimitCheck) {
|
|
110
|
+
r.defineCommand("deliveryRateLimitCheck", { numberOfKeys: 1, lua: RATE_LIMIT_LUA });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function checkRateLimit(
|
|
115
|
+
rl: RateLimitConfig,
|
|
116
|
+
tenantId: TenantId,
|
|
117
|
+
channelName: string,
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
const key = `${rl.keyPrefix ?? "delivery:rate"}:${tenantId}:${channelName}`;
|
|
120
|
+
// @cast-boundary engine-bridge — defineCommand attaches the Lua method shape at boot
|
|
121
|
+
const r = rl.redis as RedisWithLua;
|
|
122
|
+
const allowed = await r.deliveryRateLimitCheck(key, "3600", String(rl.maxPerHour));
|
|
123
|
+
return Number(allowed) === 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Idempotency: returns true the first time a key is seen, false on
|
|
127
|
+
// subsequent calls within the TTL window. Opt-in via options.idempotencyKey
|
|
128
|
+
// so callers decide when dedup matters (e.g. webhook replays, button
|
|
129
|
+
// double-clicks). Requires a Redis handle — configured via idempotencyRedis
|
|
130
|
+
// or reused from rateLimit.redis. notify() throws if the key is used without
|
|
131
|
+
// a backing Redis, so misconfigurations fail loud instead of silently double-sending.
|
|
132
|
+
async function claimIdempotency(
|
|
133
|
+
tenantId: TenantId,
|
|
134
|
+
key: string,
|
|
135
|
+
ttlSec = 86400,
|
|
136
|
+
): Promise<boolean> {
|
|
137
|
+
if (!idemRedis) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const k = `delivery:idem:${tenantId}:${key}`;
|
|
143
|
+
const res = await idemRedis.set(k, "1", "EX", ttlSec, "NX");
|
|
144
|
+
return res === "OK";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
|
|
148
|
+
if (!tenantUserIdsQuery) {
|
|
149
|
+
throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
|
|
150
|
+
}
|
|
151
|
+
const handler = registry.getQueryHandler(tenantUserIdsQuery);
|
|
152
|
+
if (!handler) {
|
|
153
|
+
throw new Error(`Tenant broadcast query "${tenantUserIdsQuery}" not found in registry`);
|
|
154
|
+
}
|
|
155
|
+
const systemUser = createSystemUser(tenantId);
|
|
156
|
+
const tenantDb = createTenantDb(db, tenantId, "system");
|
|
157
|
+
// @cast-boundary engine-payload — generic query-handler return for typed convention
|
|
158
|
+
return (await handler.handler(
|
|
159
|
+
{ type: tenantUserIdsQuery, payload: { tenantId }, user: systemUser },
|
|
160
|
+
{ db: tenantDb, registry, ...bridgeStub() },
|
|
161
|
+
)) as readonly string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildChannelContext(tenantId: TenantId): ChannelContext {
|
|
165
|
+
return { db, registry, sseBroker, tenantId };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function logDelivery(entry: DeliveryLogEntry): Promise<void> {
|
|
169
|
+
// Post-ES: each delivery attempt is a standalone event on its own
|
|
170
|
+
// aggregate stream (fresh UUID per attempt). The `delivery-log` inline
|
|
171
|
+
// projection materialises the same row shape into deliveryAttemptsTable.
|
|
172
|
+
// Low-level append() does NOT auto-fire inline projections (only the
|
|
173
|
+
// dispatcher / executor / ctx.appendEvent paths do), so we invoke
|
|
174
|
+
// runProjectionsForEvent manually to keep the write synchronous with
|
|
175
|
+
// the projection update — same TX, read-your-own-write semantics.
|
|
176
|
+
const attemptId = generateId();
|
|
177
|
+
const { tenantId, ...rest } = entry;
|
|
178
|
+
// Schema-parse to match ctx.appendEvent's guarantee: a payload drift
|
|
179
|
+
// between service + feature-registration fails loudly here instead of
|
|
180
|
+
// landing on the events-table and crashing a consumer later.
|
|
181
|
+
const payload = deliveryAttemptSchema.parse(rest);
|
|
182
|
+
const stored = await append(db, {
|
|
183
|
+
aggregateId: attemptId,
|
|
184
|
+
aggregateType: "deliveryAttempt",
|
|
185
|
+
tenantId,
|
|
186
|
+
expectedVersion: 0,
|
|
187
|
+
type: DELIVERY_ATTEMPT_EVENT,
|
|
188
|
+
payload,
|
|
189
|
+
metadata: { userId: "system" },
|
|
190
|
+
});
|
|
191
|
+
await runProjectionsForEvent(stored, registry, db);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildMessage(
|
|
195
|
+
notificationType: string,
|
|
196
|
+
data: Readonly<Record<string, unknown>> | undefined,
|
|
197
|
+
channelName: string,
|
|
198
|
+
): ChannelMessage {
|
|
199
|
+
// Look up per-channel template from notification definition
|
|
200
|
+
const notifDef = registry.getAllNotifications().get(notificationType);
|
|
201
|
+
const templateFn = notifDef?.templates?.[channelName];
|
|
202
|
+
|
|
203
|
+
if (templateFn && data) {
|
|
204
|
+
const channelData = templateFn(data as DbRow);
|
|
205
|
+
// @cast-boundary engine-payload — generic notification.data + channel-template result
|
|
206
|
+
return {
|
|
207
|
+
notificationType,
|
|
208
|
+
title: (channelData["title"] as string) ?? (data["title"] as string) ?? notificationType,
|
|
209
|
+
body: channelData["body"] as string | undefined,
|
|
210
|
+
data: channelData,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// @cast-boundary engine-payload — generic notification.data shape
|
|
215
|
+
return {
|
|
216
|
+
notificationType,
|
|
217
|
+
title: (data?.["title"] as string) ?? notificationType,
|
|
218
|
+
body: data?.["body"] as string | undefined,
|
|
219
|
+
data,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if user has disabled this notification+channel combo.
|
|
224
|
+
// Specificity order: exact > any wildcard. When only wildcards match and they
|
|
225
|
+
// disagree, "disabled wins" — the user has asked to be opted out somewhere,
|
|
226
|
+
// and an exact override is the way to punch through it. Without this rule
|
|
227
|
+
// the outcome would depend on row insertion order in the DB.
|
|
228
|
+
// Example:
|
|
229
|
+
// { type: "*", channel: "inApp", enabled: false } disables inApp globally
|
|
230
|
+
// { type: "orderAssigned", channel: "*", enabled: true } enables orderAssigned everywhere
|
|
231
|
+
// → orderAssigned on inApp: disabled (conservative) unless an exact entry overrides.
|
|
232
|
+
async function isChannelEnabled(
|
|
233
|
+
userId: string,
|
|
234
|
+
tenantId: TenantId,
|
|
235
|
+
notificationType: string,
|
|
236
|
+
channelName: string,
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
type PrefRow = {
|
|
239
|
+
readonly notificationType: string;
|
|
240
|
+
readonly channel: string;
|
|
241
|
+
readonly enabled: boolean;
|
|
242
|
+
};
|
|
243
|
+
// Drizzle's dynamic-table select() loses column types; assert once at
|
|
244
|
+
// the boundary so the rest of this function works against a typed shape.
|
|
245
|
+
const prefs = (await db
|
|
246
|
+
.select({
|
|
247
|
+
notificationType: notificationPreferencesTable.notificationType,
|
|
248
|
+
channel: notificationPreferencesTable.channel,
|
|
249
|
+
enabled: notificationPreferencesTable.enabled,
|
|
250
|
+
})
|
|
251
|
+
.from(notificationPreferencesTable)
|
|
252
|
+
.where(
|
|
253
|
+
and(
|
|
254
|
+
eq(notificationPreferencesTable.tenantId, tenantId),
|
|
255
|
+
eq(notificationPreferencesTable.userId, userId),
|
|
256
|
+
or(
|
|
257
|
+
and(
|
|
258
|
+
eq(notificationPreferencesTable.notificationType, notificationType),
|
|
259
|
+
eq(notificationPreferencesTable.channel, channelName),
|
|
260
|
+
),
|
|
261
|
+
and(
|
|
262
|
+
eq(notificationPreferencesTable.notificationType, "*"),
|
|
263
|
+
eq(notificationPreferencesTable.channel, channelName),
|
|
264
|
+
),
|
|
265
|
+
and(
|
|
266
|
+
eq(notificationPreferencesTable.notificationType, notificationType),
|
|
267
|
+
eq(notificationPreferencesTable.channel, "*"),
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
)) as readonly PrefRow[]; // @cast-boundary db-row
|
|
272
|
+
|
|
273
|
+
if (prefs.length === 0) return true;
|
|
274
|
+
|
|
275
|
+
// Exact match (both specific) wins over any wildcard
|
|
276
|
+
const exact = prefs.find(
|
|
277
|
+
(p) => p.notificationType === notificationType && p.channel === channelName,
|
|
278
|
+
);
|
|
279
|
+
if (exact) return exact.enabled;
|
|
280
|
+
|
|
281
|
+
// Only wildcards matched: any disabled entry disables delivery (deterministic
|
|
282
|
+
// and conservative — DB ordering no longer decides the outcome).
|
|
283
|
+
return !prefs.some((p) => p.enabled === false);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function deliverToUser(
|
|
287
|
+
userId: string,
|
|
288
|
+
notificationType: string,
|
|
289
|
+
data: Readonly<Record<string, unknown>> | undefined,
|
|
290
|
+
tenantId: TenantId,
|
|
291
|
+
priority: NotifyPriority,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
const channelCtx = buildChannelContext(tenantId);
|
|
294
|
+
|
|
295
|
+
for (const channel of channels) {
|
|
296
|
+
const message = buildMessage(notificationType, data, channel.name);
|
|
297
|
+
|
|
298
|
+
// Kill switch: tenant admin disabled this channel entirely
|
|
299
|
+
if (isChannelKilled) {
|
|
300
|
+
const killed = await isChannelKilled(tenantId, channel.name);
|
|
301
|
+
if (killed) {
|
|
302
|
+
await logDelivery({
|
|
303
|
+
tenantId,
|
|
304
|
+
notificationType,
|
|
305
|
+
channel: channel.name,
|
|
306
|
+
recipientId: userId,
|
|
307
|
+
recipientAddress: null,
|
|
308
|
+
status: "skipped",
|
|
309
|
+
error: "channel_disabled",
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Check preferences (critical priority skips preference check)
|
|
316
|
+
if (priority !== "critical") {
|
|
317
|
+
const enabled = await isChannelEnabled(userId, tenantId, notificationType, channel.name);
|
|
318
|
+
if (!enabled) {
|
|
319
|
+
await logDelivery({
|
|
320
|
+
tenantId,
|
|
321
|
+
notificationType,
|
|
322
|
+
channel: channel.name,
|
|
323
|
+
recipientId: userId,
|
|
324
|
+
recipientAddress: null,
|
|
325
|
+
status: "skipped",
|
|
326
|
+
error: "preference_disabled",
|
|
327
|
+
});
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Rate limiting
|
|
333
|
+
if (rateLimit) {
|
|
334
|
+
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
335
|
+
if (!allowed) {
|
|
336
|
+
await logDelivery({
|
|
337
|
+
tenantId,
|
|
338
|
+
notificationType,
|
|
339
|
+
channel: channel.name,
|
|
340
|
+
recipientId: userId,
|
|
341
|
+
recipientAddress: null,
|
|
342
|
+
status: "skipped",
|
|
343
|
+
error: "rate_limited",
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const address = await channel.resolve(userId, channelCtx);
|
|
351
|
+
if (!address) {
|
|
352
|
+
await logDelivery({
|
|
353
|
+
tenantId,
|
|
354
|
+
notificationType,
|
|
355
|
+
channel: channel.name,
|
|
356
|
+
recipientId: userId,
|
|
357
|
+
recipientAddress: null,
|
|
358
|
+
status: "skipped",
|
|
359
|
+
error: "no_address",
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const result = await channel.send(address, message, channelCtx);
|
|
365
|
+
await logDelivery({
|
|
366
|
+
tenantId,
|
|
367
|
+
notificationType,
|
|
368
|
+
channel: channel.name,
|
|
369
|
+
recipientId: userId,
|
|
370
|
+
recipientAddress: result.address ?? address,
|
|
371
|
+
status: result.status,
|
|
372
|
+
error: result.error ?? null,
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
await logDelivery({
|
|
376
|
+
tenantId,
|
|
377
|
+
notificationType,
|
|
378
|
+
channel: channel.name,
|
|
379
|
+
recipientId: userId,
|
|
380
|
+
recipientAddress: null,
|
|
381
|
+
status: "failed",
|
|
382
|
+
error: err instanceof Error ? err.message : String(err),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function deliverDirect(
|
|
389
|
+
route: Readonly<Record<string, string>>,
|
|
390
|
+
notificationType: string,
|
|
391
|
+
data: Readonly<Record<string, unknown>> | undefined,
|
|
392
|
+
tenantId: TenantId,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
const channelCtx = buildChannelContext(tenantId);
|
|
395
|
+
|
|
396
|
+
// Direct routing skips preferences (no user account) but NOT rate limit
|
|
397
|
+
// — direct sends can still be abused (webhook replays, test harnesses).
|
|
398
|
+
for (const channel of channels) {
|
|
399
|
+
const address = route[channel.name];
|
|
400
|
+
const message = buildMessage(notificationType, data, channel.name);
|
|
401
|
+
if (!address) continue;
|
|
402
|
+
|
|
403
|
+
if (rateLimit) {
|
|
404
|
+
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
405
|
+
if (!allowed) {
|
|
406
|
+
await logDelivery({
|
|
407
|
+
tenantId,
|
|
408
|
+
notificationType,
|
|
409
|
+
channel: channel.name,
|
|
410
|
+
recipientId: null,
|
|
411
|
+
recipientAddress: address,
|
|
412
|
+
status: "skipped",
|
|
413
|
+
error: "rate_limited",
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const result = await channel.send(address, message, channelCtx);
|
|
421
|
+
await logDelivery({
|
|
422
|
+
tenantId,
|
|
423
|
+
notificationType,
|
|
424
|
+
channel: channel.name,
|
|
425
|
+
recipientId: null,
|
|
426
|
+
recipientAddress: result.address ?? address,
|
|
427
|
+
status: result.status,
|
|
428
|
+
error: result.error ?? null,
|
|
429
|
+
});
|
|
430
|
+
} catch (err) {
|
|
431
|
+
await logDelivery({
|
|
432
|
+
tenantId,
|
|
433
|
+
notificationType,
|
|
434
|
+
channel: channel.name,
|
|
435
|
+
recipientId: null,
|
|
436
|
+
recipientAddress: address,
|
|
437
|
+
status: "failed",
|
|
438
|
+
error: err instanceof Error ? err.message : String(err),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
async notify(notificationType, options, _user, tenantId) {
|
|
446
|
+
const { to, route, data, idempotencyKey } = options;
|
|
447
|
+
const priority: NotifyPriority = options.priority ?? "normal";
|
|
448
|
+
|
|
449
|
+
if (idempotencyKey) {
|
|
450
|
+
const first = await claimIdempotency(tenantId, idempotencyKey);
|
|
451
|
+
if (!first) {
|
|
452
|
+
await logDelivery({
|
|
453
|
+
tenantId,
|
|
454
|
+
notificationType,
|
|
455
|
+
channel: "*",
|
|
456
|
+
recipientId: null,
|
|
457
|
+
recipientAddress: null,
|
|
458
|
+
status: "skipped",
|
|
459
|
+
error: "duplicate_idempotency_key",
|
|
460
|
+
});
|
|
461
|
+
// skip: duplicate send deduped via idempotency key, logged above
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (route) {
|
|
467
|
+
await deliverDirect(route, notificationType, data, tenantId);
|
|
468
|
+
// skip: direct route delivered, no recipient resolution needed
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (to !== undefined) {
|
|
473
|
+
let userIds: readonly string[];
|
|
474
|
+
|
|
475
|
+
if (typeof to === "string") {
|
|
476
|
+
userIds = [to];
|
|
477
|
+
} else if ("tenant" in to) {
|
|
478
|
+
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
479
|
+
} else {
|
|
480
|
+
userIds = to;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const userId of userIds) {
|
|
484
|
+
await deliverToUser(userId, notificationType, data, tenantId, priority);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Event-payload schema for the deliveryAttempt aggregate. Shared between
|
|
2
|
+
// delivery-feature.ts (registers it via r.defineEvent) and
|
|
3
|
+
// delivery-service.ts (validates payloads before the low-level append()
|
|
4
|
+
// — out-of-dispatcher writes otherwise skip schema enforcement).
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { DeliveryStatus } from "./constants";
|
|
8
|
+
|
|
9
|
+
export const deliveryAttemptSchema = z.object({
|
|
10
|
+
notificationType: z.string(),
|
|
11
|
+
channel: z.string(),
|
|
12
|
+
recipientId: z.string().nullable(),
|
|
13
|
+
recipientAddress: z.string().nullable(),
|
|
14
|
+
status: z.enum([DeliveryStatus.sent, DeliveryStatus.failed, DeliveryStatus.skipped]),
|
|
15
|
+
error: z.string().nullable(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type DeliveryAttemptPayload = z.infer<typeof deliveryAttemptSchema>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import { DELIVERY_ATTEMPT_EVENT } from "./constants";
|
|
4
|
+
import { deliveryAttemptSchema } from "./events";
|
|
5
|
+
import { logQuery } from "./handlers/log.query";
|
|
6
|
+
import { preferencesQuery } from "./handlers/preferences.query";
|
|
7
|
+
import { setPreferenceWrite } from "./handlers/set-preference.write";
|
|
8
|
+
import { deliveryAttemptsTable, notificationPreferenceEntity } from "./tables";
|
|
9
|
+
|
|
10
|
+
export function createDeliveryFeature(): FeatureDefinition {
|
|
11
|
+
return defineFeature("delivery", (r) => {
|
|
12
|
+
r.systemScope();
|
|
13
|
+
r.entity("notification-preference", notificationPreferenceEntity);
|
|
14
|
+
|
|
15
|
+
// Events-only projection source: "deliveryAttempt" is the aggregate-
|
|
16
|
+
// type on the events-table, but there's no r.entity for it — each
|
|
17
|
+
// attempt is a fresh stream, no CRUD lifecycle. Framework's
|
|
18
|
+
// boot-validator accepts the projection below because at least one
|
|
19
|
+
// apply-key is a registered domain-event (DELIVERY_ATTEMPT_EVENT).
|
|
20
|
+
r.defineEvent("attempt", deliveryAttemptSchema);
|
|
21
|
+
|
|
22
|
+
// Inline projection that materialises every delivery attempt into
|
|
23
|
+
// deliveryAttemptsTable. Runs in the SAME transaction as the low-level
|
|
24
|
+
// append(), so callers see their write immediately — no dispatcher
|
|
25
|
+
// drain needed in tests. Chosen over a MultiStreamProjection because
|
|
26
|
+
// delivery-log is a hot read-path for admin/audit UIs that expect
|
|
27
|
+
// read-your-own-write semantics.
|
|
28
|
+
r.projection({
|
|
29
|
+
name: "delivery-log",
|
|
30
|
+
source: "deliveryAttempt",
|
|
31
|
+
table: deliveryAttemptsTable,
|
|
32
|
+
apply: {
|
|
33
|
+
[DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
|
|
34
|
+
const p = event.payload as z.infer<typeof deliveryAttemptSchema>;
|
|
35
|
+
// PK = aggregateId — replaying the same event twice conflicts on
|
|
36
|
+
// the PK rather than silently duplicating the log row.
|
|
37
|
+
await tx.insert(deliveryAttemptsTable).values({
|
|
38
|
+
id: event.aggregateId,
|
|
39
|
+
tenantId: event.tenantId,
|
|
40
|
+
notificationType: p.notificationType,
|
|
41
|
+
channel: p.channel,
|
|
42
|
+
recipientId: p.recipientId,
|
|
43
|
+
recipientAddress: p.recipientAddress,
|
|
44
|
+
status: p.status,
|
|
45
|
+
error: p.error,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Extension points: channels and renderers register as features
|
|
52
|
+
r.extendsRegistrar("deliveryChannel", {
|
|
53
|
+
onRegister: () => {},
|
|
54
|
+
});
|
|
55
|
+
r.extendsRegistrar("notificationRenderer", {
|
|
56
|
+
onRegister: () => {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const handlers = {
|
|
60
|
+
setPreference: r.writeHandler(setPreferenceWrite),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const queries = {
|
|
64
|
+
log: r.queryHandler(logQuery),
|
|
65
|
+
preferences: r.queryHandler(preferencesQuery),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return { handlers, queries };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { desc } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { deliveryAttemptsTable } from "../tables";
|
|
5
|
+
|
|
6
|
+
export const logQuery = defineQueryHandler({
|
|
7
|
+
name: "log",
|
|
8
|
+
schema: z.object({
|
|
9
|
+
limit: z.number().min(1).max(100).default(50),
|
|
10
|
+
}),
|
|
11
|
+
access: { roles: ["Admin", "SystemAdmin"] },
|
|
12
|
+
handler: async (query, ctx) => {
|
|
13
|
+
const rows = await ctx.db
|
|
14
|
+
.select()
|
|
15
|
+
.from(deliveryAttemptsTable)
|
|
16
|
+
.orderBy(desc(deliveryAttemptsTable.createdAt))
|
|
17
|
+
.limit(query.payload.limit);
|
|
18
|
+
|
|
19
|
+
return { rows };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { notificationPreferencesTable } from "../tables";
|
|
5
|
+
|
|
6
|
+
export const preferencesQuery = defineQueryHandler({
|
|
7
|
+
name: "preferences",
|
|
8
|
+
schema: z.object({}),
|
|
9
|
+
access: { openToAll: true },
|
|
10
|
+
handler: async (query, ctx) => {
|
|
11
|
+
const rows = await ctx.db
|
|
12
|
+
.select()
|
|
13
|
+
.from(notificationPreferencesTable)
|
|
14
|
+
.where(eq(notificationPreferencesTable.userId, query.user.id));
|
|
15
|
+
|
|
16
|
+
return { rows };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { upsertPreference } from "../upsert-preference";
|
|
4
|
+
|
|
5
|
+
export const setPreferenceWrite = defineWriteHandler({
|
|
6
|
+
name: "setPreference",
|
|
7
|
+
schema: z.object({
|
|
8
|
+
notificationType: z.string(), // qualified name or "*"
|
|
9
|
+
channel: z.string(), // "inApp", "email", etc. or "*"
|
|
10
|
+
enabled: z.boolean(),
|
|
11
|
+
}),
|
|
12
|
+
// Every user manages their own preferences; tenant+user scoping is on the WHERE.
|
|
13
|
+
access: { openToAll: true },
|
|
14
|
+
handler: async (event, ctx) => {
|
|
15
|
+
const { notificationType, channel, enabled } = event.payload;
|
|
16
|
+
const { id: userId, tenantId } = event.user;
|
|
17
|
+
|
|
18
|
+
const result = await upsertPreference(ctx.db, event.user, {
|
|
19
|
+
tenantId,
|
|
20
|
+
userId,
|
|
21
|
+
notificationType,
|
|
22
|
+
channel,
|
|
23
|
+
enabled,
|
|
24
|
+
});
|
|
25
|
+
if (!result.isSuccess) return result;
|
|
26
|
+
return { isSuccess: true, data: { notificationType, channel, enabled } };
|
|
27
|
+
},
|
|
28
|
+
});
|