@k2b/cloud 0.6.0-rc.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/README.md +98 -0
- package/package.json +128 -0
- package/public/logo.svg +1 -0
- package/scripts/app-favicon.test.ts +49 -0
- package/scripts/app-favicon.ts +51 -0
- package/scripts/build.ts +205 -0
- package/scripts/preload.ts +164 -0
- package/scripts/sync-recovery-smoke.ts +200 -0
- package/src/_internal/app-favicon.ts +2 -0
- package/src/_internal/app-presentation.ts +61 -0
- package/src/_internal/app-workspace-state.ts +24 -0
- package/src/_internal/bounded-json.ts +47 -0
- package/src/_internal/build-metadata.ts +9 -0
- package/src/_internal/capabilities.ts +1118 -0
- package/src/_internal/define-app.ts +801 -0
- package/src/_internal/heartbeat.ts +167 -0
- package/src/_internal/help-catalog.ts +241 -0
- package/src/_internal/help.ts +95 -0
- package/src/_internal/nats-connection.ts +34 -0
- package/src/_internal/page-responses.ts +40 -0
- package/src/_internal/process-sync.ts +117 -0
- package/src/_internal/readiness.ts +3 -0
- package/src/_internal/registry-validation.ts +136 -0
- package/src/_internal/registry-watch.ts +52 -0
- package/src/_internal/registry.ts +338 -0
- package/src/_internal/runtime-compatibility.ts +64 -0
- package/src/_internal/runtime-context.ts +84 -0
- package/src/_internal/runtime-watcher.ts +78 -0
- package/src/_internal/static-assets.ts +86 -0
- package/src/_internal/status-preserving-ssr.ts +49 -0
- package/src/access/PermissionEditor.tsx +486 -0
- package/src/access/ResourceApiKeys.tsx +271 -0
- package/src/access/messages.ts +121 -0
- package/src/access/ui.ts +16 -0
- package/src/account/Avatar.tsx +28 -0
- package/src/account/EntitySearch.tsx +363 -0
- package/src/account/avatar-upload-dialog.tsx +197 -0
- package/src/account/avatar-upload.ts +149 -0
- package/src/account/ui.ts +3 -0
- package/src/ai/admin.ts +47 -0
- package/src/ai/approval-routes.ts +130 -0
- package/src/ai/approvals.ts +139 -0
- package/src/ai/assistant-models.ts +61 -0
- package/src/ai/attachments.ts +40 -0
- package/src/ai/browser.ts +92 -0
- package/src/ai/capabilities.ts +714 -0
- package/src/ai/capability-execution.ts +151 -0
- package/src/ai/chat/blocks.render.test.tsx +1108 -0
- package/src/ai/chat/blocks.tsx +730 -0
- package/src/ai/chat/builtin-tools.tsx +428 -0
- package/src/ai/chat/capability-table.tsx +87 -0
- package/src/ai/chat/composer-adapter.ts +169 -0
- package/src/ai/chat/file-tools.tsx +81 -0
- package/src/ai/chat/message-actions.tsx +370 -0
- package/src/ai/chat/message-utils.ts +479 -0
- package/src/ai/chat/messages.ts +43 -0
- package/src/ai/chat/presentation.tsx +395 -0
- package/src/ai/chat/primitives.tsx +3 -0
- package/src/ai/chat/tool-disclosure.tsx +41 -0
- package/src/ai/chat/turn-navigator-utils.ts +28 -0
- package/src/ai/chat/turn-navigator.tsx +164 -0
- package/src/ai/chat/user-message.tsx +149 -0
- package/src/ai/chat/visual-tools.tsx +435 -0
- package/src/ai/chat/web-tools.tsx +202 -0
- package/src/ai/chat-task-contracts.ts +113 -0
- package/src/ai/chat-tasks.ts +952 -0
- package/src/ai/client/controller.ts +1211 -0
- package/src/ai/client/file-source.ts +95 -0
- package/src/ai/client/live-connection.ts +140 -0
- package/src/ai/client/projection.ts +178 -0
- package/src/ai/client/transport.ts +111 -0
- package/src/ai/compaction.ts +182 -0
- package/src/ai/credentials.ts +124 -0
- package/src/ai/default-tool-contracts.ts +108 -0
- package/src/ai/default-tools.ts +127 -0
- package/src/ai/enrich.ts +280 -0
- package/src/ai/executor.ts +1624 -0
- package/src/ai/fetch-file-tool.ts +313 -0
- package/src/ai/file-context.ts +92 -0
- package/src/ai/file-media-type.ts +28 -0
- package/src/ai/file-mount.ts +16 -0
- package/src/ai/file-tools.ts +566 -0
- package/src/ai/files-store.ts +390 -0
- package/src/ai/firecrawl-tools.ts +305 -0
- package/src/ai/grids-skill.ts +126 -0
- package/src/ai/http.ts +265 -0
- package/src/ai/index.ts +337 -0
- package/src/ai/kit-skill.ts +22 -0
- package/src/ai/limits.ts +5 -0
- package/src/ai/live-events.ts +289 -0
- package/src/ai/live-messages.ts +45 -0
- package/src/ai/live-outbox.ts +102 -0
- package/src/ai/live-routes.ts +412 -0
- package/src/ai/live.ts +2 -0
- package/src/ai/maintenance.ts +302 -0
- package/src/ai/markdown-pdf-tool.ts +95 -0
- package/src/ai/memories.ts +605 -0
- package/src/ai/memory-learning-runs.ts +222 -0
- package/src/ai/memory-learning.ts +619 -0
- package/src/ai/memory-tool.ts +120 -0
- package/src/ai/memory-workflow-evidence.ts +158 -0
- package/src/ai/migrate.ts +2082 -0
- package/src/ai/model-access-migrate.ts +30 -0
- package/src/ai/model-access.ts +191 -0
- package/src/ai/personal-agent.ts +6 -0
- package/src/ai/prefs.ts +72 -0
- package/src/ai/project-tool.ts +94 -0
- package/src/ai/projects-routes.ts +277 -0
- package/src/ai/projects.ts +902 -0
- package/src/ai/protocol.ts +279 -0
- package/src/ai/provider.ts +73 -0
- package/src/ai/public-projection.ts +25 -0
- package/src/ai/resource-markers.ts +31 -0
- package/src/ai/resource-refs.ts +70 -0
- package/src/ai/routes.ts +1131 -0
- package/src/ai/runtime-tools.ts +39 -0
- package/src/ai/runtime.ts +544 -0
- package/src/ai/settings.ts +529 -0
- package/src/ai/short-id.ts +45 -0
- package/src/ai/skill-catalog.ts +73 -0
- package/src/ai/skill-format.ts +145 -0
- package/src/ai/skill-seeds.ts +430 -0
- package/src/ai/skill-tool.ts +81 -0
- package/src/ai/skills-routes.ts +143 -0
- package/src/ai/skills.ts +934 -0
- package/src/ai/solid.ts +27 -0
- package/src/ai/store.ts +3113 -0
- package/src/ai/stream.ts +308 -0
- package/src/ai/structured-runs.ts +66 -0
- package/src/ai/structured.ts +186 -0
- package/src/ai/system-prompt.ts +170 -0
- package/src/ai/task-prompt.ts +22 -0
- package/src/ai/timeline.ts +148 -0
- package/src/ai/tool-audit.ts +177 -0
- package/src/ai/tool-result-budget.ts +16 -0
- package/src/ai/tools.ts +155 -0
- package/src/ai/types.ts +967 -0
- package/src/ai/ui.tsx +39 -0
- package/src/ai/usage-migrate.ts +81 -0
- package/src/ai/usage.ts +315 -0
- package/src/ai/validate.ts +54 -0
- package/src/ai/vision-tool.ts +79 -0
- package/src/api/accounts-entities.ts +174 -0
- package/src/api/admin-ai-projects.ts +74 -0
- package/src/api/admin-ai-skills.ts +76 -0
- package/src/api/admin-ai-usage.ts +42 -0
- package/src/api/admin-core-settings.ts +508 -0
- package/src/api/admin-identity.ts +148 -0
- package/src/api/admin-lifecycle.ts +206 -0
- package/src/api/admin-linux-identities.ts +66 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/app-approval.ts +129 -0
- package/src/api/apps.ts +51 -0
- package/src/api/auth/schemas.ts +54 -0
- package/src/api/auth.ts +370 -0
- package/src/api/capabilities.ts +658 -0
- package/src/api/help-messages.ts +19 -0
- package/src/api/help.ts +78 -0
- package/src/api/index.ts +108 -0
- package/src/api/mcp.ts +744 -0
- package/src/api/me-rail.ts +45 -0
- package/src/api/me.ts +753 -0
- package/src/api/search/schemas.ts +79 -0
- package/src/api/search.bench.ts +185 -0
- package/src/api/search.ts +407 -0
- package/src/api/sync-ops.ts +79 -0
- package/src/api/widgets.ts +143 -0
- package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
- package/src/browser/CloudResourceSearch.tsx +791 -0
- package/src/browser/app-approval-vault.ts +305 -0
- package/src/browser/app-approval.ts +234 -0
- package/src/browser/live-websocket.ts +268 -0
- package/src/browser/locale-preference.ts +25 -0
- package/src/browser/notification-target.ts +27 -0
- package/src/browser/notification-timeout.ts +14 -0
- package/src/browser/notifications.ts +181 -0
- package/src/browser/resource-clipboard.ts +70 -0
- package/src/browser/resource-picker.tsx +39 -0
- package/src/browser/resource-search-messages.ts +55 -0
- package/src/browser/resource-search.ts +27 -0
- package/src/capabilities/client.ts +149 -0
- package/src/capabilities/response.ts +39 -0
- package/src/capabilities/server.ts +215 -0
- package/src/capabilities/signals.ts +4 -0
- package/src/capabilities/testing.ts +12 -0
- package/src/capabilities/types.ts +32 -0
- package/src/cli/access.ts +499 -0
- package/src/cli/account.ts +382 -0
- package/src/cli/admin/account-administration.ts +45 -0
- package/src/cli/admin/account-categories.ts +49 -0
- package/src/cli/admin/ai-usage.ts +118 -0
- package/src/cli/admin/app-sign-in.ts +49 -0
- package/src/cli/admin/data.ts +343 -0
- package/src/cli/admin/documentation.ts +34 -0
- package/src/cli/admin/gateway.ts +155 -0
- package/src/cli/admin/index.ts +61 -0
- package/src/cli/admin/instance.ts +250 -0
- package/src/cli/admin/jobs.ts +223 -0
- package/src/cli/admin/legal.ts +122 -0
- package/src/cli/admin/linux.ts +53 -0
- package/src/cli/admin/logs.ts +324 -0
- package/src/cli/admin/metrics.ts +142 -0
- package/src/cli/admin/nats.ts +167 -0
- package/src/cli/admin/notifications.ts +516 -0
- package/src/cli/admin/shared.ts +113 -0
- package/src/cli/admin/sync.ts +188 -0
- package/src/cli/admin/telemetry.ts +314 -0
- package/src/cli/admin/webhooks.ts +222 -0
- package/src/cli/admin/workflows.ts +367 -0
- package/src/cli/apps.ts +40 -0
- package/src/cli/capabilities.ts +239 -0
- package/src/cli/commands.ts +604 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/locale.ts +31 -0
- package/src/cli/output.ts +58 -0
- package/src/clients/core.ts +20 -0
- package/src/config/env.ts +54 -0
- package/src/config/index.ts +6 -0
- package/src/contracts/account-categories.ts +62 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app-approval.ts +197 -0
- package/src/contracts/app.ts +162 -0
- package/src/contracts/capabilities.ts +484 -0
- package/src/contracts/index.ts +14 -0
- package/src/contracts/notification-live.ts +66 -0
- package/src/contracts/notification-types.ts +235 -0
- package/src/contracts/posix.ts +69 -0
- package/src/contracts/profile.ts +85 -0
- package/src/contracts/rail-preferences.ts +53 -0
- package/src/contracts/registry.ts +110 -0
- package/src/contracts/resource-clipboard.ts +33 -0
- package/src/contracts/settings-types.ts +98 -0
- package/src/contracts/shared.ts +408 -0
- package/src/contracts/user-notifications.ts +101 -0
- package/src/contracts/widgets.ts +183 -0
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/index.ts +58 -0
- package/src/server/actor.ts +41 -0
- package/src/server/api/index.ts +2 -0
- package/src/server/api/respond.ts +96 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/help.ts +187 -0
- package/src/server/index.ts +89 -0
- package/src/server/locale.ts +55 -0
- package/src/server/middleware/auth.ts +386 -0
- package/src/server/middleware/index.ts +28 -0
- package/src/server/middleware/invocation.ts +55 -0
- package/src/server/middleware/middleware.ts +65 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +125 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/route-template.ts +46 -0
- package/src/server/middleware/runtime.ts +22 -0
- package/src/server/middleware/settings.ts +55 -0
- package/src/server/middleware/validator.ts +49 -0
- package/src/server/middleware/workload.ts +22 -0
- package/src/server/ratelimit.ts +151 -0
- package/src/server/request-cookies.ts +18 -0
- package/src/server/services/access.ts +644 -0
- package/src/server/services/freeipa/client.ts +137 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +123 -0
- package/src/server/services/freeipa/test-certificates.ts +44 -0
- package/src/server/services/freeipa/tls.ts +44 -0
- package/src/server/services/freeipa/transport.ts +116 -0
- package/src/server/services/freeipa/util.ts +59 -0
- package/src/server/services/geo.ts +198 -0
- package/src/server/services/index.ts +48 -0
- package/src/server/services/pagination.ts +22 -0
- package/src/server/services/services.ts +13 -0
- package/src/server/time.ts +33 -0
- package/src/services/account-category-policy.ts +51 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +940 -0
- package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
- package/src/services/account-lifecycle/notification-sender.ts +13 -0
- package/src/services/account-lifecycle/scheduler.ts +506 -0
- package/src/services/account-model.ts +22 -0
- package/src/services/accounts/app.ts +1671 -0
- package/src/services/accounts/authz.ts +44 -0
- package/src/services/accounts/avatar.ts +120 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +46 -0
- package/src/services/accounts/duplicate-emails.ts +82 -0
- package/src/services/accounts/entities.ts +622 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +272 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +66 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +499 -0
- package/src/services/accounts/model.ts +129 -0
- package/src/services/accounts/notification-sender.ts +31 -0
- package/src/services/accounts/posix.ts +386 -0
- package/src/services/accounts/request-policy.ts +11 -0
- package/src/services/accounts/switching.ts +126 -0
- package/src/services/accounts/users.ts +745 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/app-approval.ts +498 -0
- package/src/services/audit/index.ts +438 -0
- package/src/services/auth-flows/index.ts +15 -0
- package/src/services/auth-flows/ipa.ts +197 -0
- package/src/services/auth-flows/magic-link.ts +185 -0
- package/src/services/auth-flows/notification-sender.ts +10 -0
- package/src/services/auth-flows/password-reset.ts +268 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/document-extraction.ts +148 -0
- package/src/services/freeipa-config.ts +179 -0
- package/src/services/gateway.ts +186 -0
- package/src/services/identity/constants.ts +18 -0
- package/src/services/identity/index.ts +52 -0
- package/src/services/identity/invocation-actor.ts +120 -0
- package/src/services/identity/invocation-authority.ts +53 -0
- package/src/services/identity/invocation-operations.ts +15 -0
- package/src/services/identity/invocation-token.ts +276 -0
- package/src/services/identity/key-config.ts +41 -0
- package/src/services/identity/key-ring.ts +530 -0
- package/src/services/identity/metrics.ts +29 -0
- package/src/services/identity/routes.ts +18 -0
- package/src/services/identity/runtime-config.ts +88 -0
- package/src/services/identity/session-token.ts +152 -0
- package/src/services/identity/signer-refresh.ts +44 -0
- package/src/services/identity/workload-auth.ts +64 -0
- package/src/services/index.ts +187 -0
- package/src/services/ipa/auth.ts +216 -0
- package/src/services/ipa/connection.ts +57 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/groups.ts +722 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/posix.ts +19 -0
- package/src/services/ipa/profile.ts +132 -0
- package/src/services/ipa/runtime.ts +28 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.ts +100 -0
- package/src/services/ipa/sync.ts +862 -0
- package/src/services/ipa/users.ts +806 -0
- package/src/services/legal-consent.ts +76 -0
- package/src/services/logging/index.ts +477 -0
- package/src/services/logging/redaction.ts +20 -0
- package/src/services/logging/trace.ts +1231 -0
- package/src/services/mandates/index.ts +855 -0
- package/src/services/mandates/policy.ts +85 -0
- package/src/services/network-security.ts +31 -0
- package/src/services/notifications/batches.ts +722 -0
- package/src/services/notifications/browser.ts +252 -0
- package/src/services/notifications/catalog.ts +113 -0
- package/src/services/notifications/channels.ts +104 -0
- package/src/services/notifications/dispatcher.ts +139 -0
- package/src/services/notifications/email.ts +118 -0
- package/src/services/notifications/index.ts +633 -0
- package/src/services/notifications/live.ts +50 -0
- package/src/services/notifications/observability.ts +407 -0
- package/src/services/notifications/platform.ts +421 -0
- package/src/services/notifications/runtime.ts +120 -0
- package/src/services/notifications/user.ts +248 -0
- package/src/services/notifications/web-push-transport.ts +135 -0
- package/src/services/oauth-tokens.ts +209 -0
- package/src/services/outbox.ts +187 -0
- package/src/services/pdf/gotenberg.ts +290 -0
- package/src/services/pdf/index.ts +43 -0
- package/src/services/pdf/markdown.ts +209 -0
- package/src/services/pdf/template-preview.ts +122 -0
- package/src/services/postgres.ts +76 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +51 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +263 -0
- package/src/services/rail-preferences.ts +27 -0
- package/src/services/runtime-lifecycle.ts +191 -0
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.ts +891 -0
- package/src/services/service-accounts.ts +184 -0
- package/src/services/session/index.ts +236 -0
- package/src/services/session/test-fixture.ts +18 -0
- package/src/services/session/user.ts +144 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +86 -0
- package/src/services/settings/core-settings.ts +796 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +374 -0
- package/src/services/settings/index.ts +215 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +241 -0
- package/src/services/settings/templates.ts +4 -0
- package/src/services/sync-ops.ts +403 -0
- package/src/services/topic-cursor.ts +22 -0
- package/src/services/weather/forecast.ts +323 -0
- package/src/services/weather/geo.ts +119 -0
- package/src/services/weather/index.ts +67 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +133 -0
- package/src/services/weather/migrate.ts +45 -0
- package/src/services/weather/settings.ts +66 -0
- package/src/services/weather/short-id.ts +57 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/services/webauthn.ts +385 -0
- package/src/shared/account-action-notice.ts +70 -0
- package/src/shared/account-display.ts +15 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/ai-platform-prompt.ts +134 -0
- package/src/shared/ai-usage.ts +49 -0
- package/src/shared/app-presentation.ts +48 -0
- package/src/shared/app-url.ts +12 -0
- package/src/shared/branding.ts +14 -0
- package/src/shared/capability-messages.ts +70 -0
- package/src/shared/email-html.ts +54 -0
- package/src/shared/format.ts +91 -0
- package/src/shared/help.ts +68 -0
- package/src/shared/icons.ts +400 -0
- package/src/shared/index.ts +54 -0
- package/src/shared/locale.ts +48 -0
- package/src/shared/login-method.ts +44 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +105 -0
- package/src/shared/markdown/extensions/guided-help.ts +100 -0
- package/src/shared/markdown/extensions/images.ts +79 -0
- package/src/shared/markdown/extensions/info-blocks.ts +97 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +43 -0
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +109 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +193 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/network-address.ts +106 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/template-rendering.ts +140 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +53 -0
- package/src/ssr/AdminSidebar.tsx +50 -0
- package/src/ssr/AppLaunchpad.island.tsx +236 -0
- package/src/ssr/AppWorkspaceController.island.tsx +21 -0
- package/src/ssr/BrowserNotifications.island.tsx +135 -0
- package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
- package/src/ssr/GlobalSearchDialog.tsx +35 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
- package/src/ssr/Layout.render.test.tsx +148 -0
- package/src/ssr/Layout.tsx +292 -0
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutFooter.tsx +22 -0
- package/src/ssr/LayoutHeader.tsx +81 -0
- package/src/ssr/LayoutHelp.tsx +869 -0
- package/src/ssr/LayoutPreferences.island.tsx +36 -0
- package/src/ssr/LayoutRail.tsx +58 -0
- package/src/ssr/MinimalLayout.tsx +46 -0
- package/src/ssr/PageError.tsx +29 -0
- package/src/ssr/ProfilePreferences.island.tsx +92 -0
- package/src/ssr/RailApps.island.tsx +61 -0
- package/src/ssr/RailEditor.tsx +293 -0
- package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/admin-active-link.ts +47 -0
- package/src/ssr/admin-navigation.ts +78 -0
- package/src/ssr/app-appearance.ts +23 -0
- package/src/ssr/app-navigation.ts +41 -0
- package/src/ssr/help-messages.ts +104 -0
- package/src/ssr/index.ts +14 -0
- package/src/ssr/islands/SearchBar.island.tsx +80 -0
- package/src/ssr/islands/index.ts +16 -0
- package/src/ssr/layout-context.ts +17 -0
- package/src/ssr/layout-help-markdown.ts +9 -0
- package/src/ssr/layout-help-navigation.ts +24 -0
- package/src/ssr/layout-help-url.ts +5 -0
- package/src/ssr/layout-runtime.ts +22 -0
- package/src/ssr/page-error-messages.ts +25 -0
- package/src/ssr/platform-messages.ts +135 -0
- package/src/ssr/preference-controller.ts +44 -0
- package/src/ssr/profile-preferences-messages.ts +29 -0
- package/src/ssr/rail-context.ts +21 -0
- package/src/ssr/rail-messages.ts +66 -0
- package/src/ssr/rail-navigation.ts +60 -0
- package/src/ssr/runtime.ts +31 -0
- package/src/ssr/url-filter.ts +127 -0
- package/src/styles/base-popover.css +36 -0
- package/src/styles/effects.css +851 -0
- package/src/styles/global.css +268 -0
- package/src/styles/input.css +58 -0
- package/src/styles/tokens.css +230 -0
- package/src/styles/utilities-buttons.css +405 -0
- package/src/styles/utilities-data.css +33 -0
- package/src/styles/utilities-feedback.css +232 -0
- package/src/styles/utilities-layout.css +603 -0
- package/src/styles/utilities-markdown-editor.css +525 -0
- package/src/styles/utilities-markdown-table.css +234 -0
- package/src/styles/utilities-navigation.css +1054 -0
- package/src/types/ambient.d.ts +24 -0
- package/src/workflows/ai/index.ts +17 -0
- package/src/workflows/ai/runtime.ts +286 -0
- package/src/workflows/ai/store.ts +314 -0
- package/src/workflows/ai/types.ts +111 -0
- package/src/workflows/ai-actions.ts +269 -0
- package/src/workflows/authoring.ts +88 -0
- package/src/workflows/builtins.ts +193 -0
- package/src/workflows/contracts.ts +280 -0
- package/src/workflows/definition.ts +334 -0
- package/src/workflows/editor.ts +113 -0
- package/src/workflows/index.ts +71 -0
- package/src/workflows/language/binder.ts +45 -0
- package/src/workflows/language/canonical.ts +29 -0
- package/src/workflows/language/compiler.ts +396 -0
- package/src/workflows/language/expressions.ts +34 -0
- package/src/workflows/language/index.ts +20 -0
- package/src/workflows/language/references.ts +55 -0
- package/src/workflows/language/schema.ts +134 -0
- package/src/workflows/language/strict-yaml.ts +131 -0
- package/src/workflows/manifest.ts +24 -0
- package/src/workflows/module.ts +12 -0
- package/src/workflows/runtime/coordinator.ts +115 -0
- package/src/workflows/runtime/executor.ts +1003 -0
- package/src/workflows/runtime/index.ts +60 -0
- package/src/workflows/runtime/instant.ts +8 -0
- package/src/workflows/runtime/ports.ts +213 -0
- package/src/workflows/runtime/schedule.ts +203 -0
- package/src/workflows/runtime/trigger.ts +37 -0
- package/src/workflows/runtime/wake.ts +13 -0
- package/src/workflows/store/actions.ts +438 -0
- package/src/workflows/store/budget.ts +145 -0
- package/src/workflows/store/definitions.ts +345 -0
- package/src/workflows/store/events.ts +383 -0
- package/src/workflows/store/index.ts +107 -0
- package/src/workflows/store/observability.ts +594 -0
- package/src/workflows/store/runs.ts +975 -0
- package/src/workflows/store/transaction.ts +23 -0
- package/src/workflows/store/worker.ts +439 -0
- package/src/workflows/testing/conformance.ts +72 -0
- package/src/workflows/testing/index.ts +11 -0
- package/src/workflows/testing/process-fixtures.ts +275 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
3
|
+
import type { Worker } from "@k2b/sync";
|
|
4
|
+
import { sql } from "bun";
|
|
5
|
+
import { lazySync } from "../../_internal/process-sync";
|
|
6
|
+
import { markdown } from "../../shared/markdown";
|
|
7
|
+
import { logger, trace } from "../logging";
|
|
8
|
+
import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
9
|
+
|
|
10
|
+
import { sendEmail } from "./email";
|
|
11
|
+
|
|
12
|
+
const log = logger("notifications:batches");
|
|
13
|
+
const CHUNK_SIZE = 100;
|
|
14
|
+
const MAX_PAGE = 10_000;
|
|
15
|
+
const MAX_PER_PAGE = 100;
|
|
16
|
+
|
|
17
|
+
export type NotificationBatchStatus = "draft" | "ready" | "running" | "completed" | "completed_with_errors" | "failed" | "cancelled";
|
|
18
|
+
export type NotificationBatchRecipientStatus = "pending" | "sending" | "sent" | "skipped" | "error";
|
|
19
|
+
|
|
20
|
+
export type NotificationBatchSelection = {
|
|
21
|
+
userIds?: string[];
|
|
22
|
+
groupIds?: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type NotificationBatchPreview = {
|
|
26
|
+
targetCount: number;
|
|
27
|
+
deliverableCount: number;
|
|
28
|
+
skippedNoEmailCount: number;
|
|
29
|
+
duplicateCount: number;
|
|
30
|
+
recipientHash: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type NotificationBatch = {
|
|
34
|
+
id: string;
|
|
35
|
+
subject: string;
|
|
36
|
+
bodyMarkdown: string;
|
|
37
|
+
bodyHtml: string;
|
|
38
|
+
selection: NotificationBatchSelection;
|
|
39
|
+
selectionHash: string;
|
|
40
|
+
status: NotificationBatchStatus;
|
|
41
|
+
createdBy: string | null;
|
|
42
|
+
finalizedBy: string | null;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
finalizedAt: string | null;
|
|
45
|
+
startedAt: string | null;
|
|
46
|
+
completedAt: string | null;
|
|
47
|
+
targetCount: number;
|
|
48
|
+
deliverableCount: number;
|
|
49
|
+
sentCount: number;
|
|
50
|
+
skippedCount: number;
|
|
51
|
+
errorCount: number;
|
|
52
|
+
lastError: string | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type NotificationBatchRecipient = {
|
|
56
|
+
batchId: string;
|
|
57
|
+
userId: string;
|
|
58
|
+
recipient: string | null;
|
|
59
|
+
uid: string;
|
|
60
|
+
displayName: string;
|
|
61
|
+
avatarHash: string | null;
|
|
62
|
+
provider: "local" | "ipa";
|
|
63
|
+
profile: "user" | "guest";
|
|
64
|
+
status: NotificationBatchRecipientStatus;
|
|
65
|
+
notificationId: string | null;
|
|
66
|
+
error: string | null;
|
|
67
|
+
attemptCount: number;
|
|
68
|
+
sentAt: string | null;
|
|
69
|
+
updatedAt: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type RecipientCandidate = {
|
|
73
|
+
id: string;
|
|
74
|
+
uid: string;
|
|
75
|
+
display_name: string;
|
|
76
|
+
mail: string | null;
|
|
77
|
+
provider: "local" | "ipa";
|
|
78
|
+
profile: "user" | "guest";
|
|
79
|
+
source_hits: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type BatchRow = Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
const normalizeIds = (values: string[] | null | undefined): string[] => [...new Set((values ?? []).filter(Boolean))].sort();
|
|
85
|
+
|
|
86
|
+
const normalizeSelection = (selection: NotificationBatchSelection): NotificationBatchSelection => ({
|
|
87
|
+
userIds: normalizeIds(selection.userIds),
|
|
88
|
+
groupIds: normalizeIds(selection.groupIds),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const hasAudience = (selection: NotificationBatchSelection): boolean =>
|
|
92
|
+
(selection.userIds?.length ?? 0) > 0 || (selection.groupIds?.length ?? 0) > 0;
|
|
93
|
+
|
|
94
|
+
const hasLegacyAudienceSelection = (selection: NotificationBatchSelection): boolean => {
|
|
95
|
+
const raw = selection as NotificationBatchSelection & Record<string, unknown>;
|
|
96
|
+
const mode = typeof raw.mode === "string" ? raw.mode : undefined;
|
|
97
|
+
const rules = Array.isArray(raw.rules) ? raw.rules : [];
|
|
98
|
+
const accountManagers = raw.accountManagers as { mode?: string; groupIds?: unknown } | undefined;
|
|
99
|
+
const managerMode = accountManagers && typeof accountManagers.mode === "string" ? accountManagers.mode : undefined;
|
|
100
|
+
const managerGroupIds = Array.isArray(accountManagers?.groupIds) ? accountManagers.groupIds : [];
|
|
101
|
+
const providers = Array.isArray(raw.providers) ? raw.providers : [];
|
|
102
|
+
const profiles = Array.isArray(raw.profiles) ? raw.profiles : [];
|
|
103
|
+
return (
|
|
104
|
+
(mode !== undefined && mode !== "specific") ||
|
|
105
|
+
rules.length > 0 ||
|
|
106
|
+
raw.all === true ||
|
|
107
|
+
raw.includeGroupMembers === false ||
|
|
108
|
+
(managerMode !== undefined && managerMode !== "none") ||
|
|
109
|
+
managerGroupIds.length > 0 ||
|
|
110
|
+
providers.length > 0 ||
|
|
111
|
+
profiles.length > 0
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const selectionHash = (selection: NotificationBatchSelection): string =>
|
|
116
|
+
createHash("sha256")
|
|
117
|
+
.update(JSON.stringify(normalizeSelection(selection)))
|
|
118
|
+
.digest("hex");
|
|
119
|
+
|
|
120
|
+
const recipientHash = (candidates: RecipientCandidate[]): string =>
|
|
121
|
+
createHash("sha256")
|
|
122
|
+
.update(
|
|
123
|
+
JSON.stringify(
|
|
124
|
+
candidates
|
|
125
|
+
.filter((candidate) => candidate.mail)
|
|
126
|
+
.map((candidate) => candidate.id)
|
|
127
|
+
.sort(),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
.digest("hex");
|
|
131
|
+
|
|
132
|
+
const mapBatch = (row: BatchRow): NotificationBatch => ({
|
|
133
|
+
id: row.id as string,
|
|
134
|
+
subject: row.subject as string,
|
|
135
|
+
bodyMarkdown: row.body_markdown as string,
|
|
136
|
+
bodyHtml: row.body_html as string,
|
|
137
|
+
selection: (parsePgJsonValue(row.selection) ?? {}) as NotificationBatchSelection,
|
|
138
|
+
selectionHash: row.selection_hash as string,
|
|
139
|
+
status: row.status as NotificationBatchStatus,
|
|
140
|
+
createdBy: row.created_by as string | null,
|
|
141
|
+
finalizedBy: row.finalized_by as string | null,
|
|
142
|
+
createdAt: (row.created_at as Date).toISOString(),
|
|
143
|
+
finalizedAt: row.finalized_at ? (row.finalized_at as Date).toISOString() : null,
|
|
144
|
+
startedAt: row.started_at ? (row.started_at as Date).toISOString() : null,
|
|
145
|
+
completedAt: row.completed_at ? (row.completed_at as Date).toISOString() : null,
|
|
146
|
+
targetCount: Number(row.target_count ?? 0),
|
|
147
|
+
deliverableCount: Number(row.deliverable_count ?? 0),
|
|
148
|
+
sentCount: Number(row.sent_count ?? 0),
|
|
149
|
+
skippedCount: Number(row.skipped_count ?? 0),
|
|
150
|
+
errorCount: Number(row.error_count ?? 0),
|
|
151
|
+
lastError: row.last_error as string | null,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const mapRecipient = (row: BatchRow): NotificationBatchRecipient => ({
|
|
155
|
+
batchId: row.batch_id as string,
|
|
156
|
+
userId: row.user_id as string,
|
|
157
|
+
recipient: row.recipient as string | null,
|
|
158
|
+
uid: row.uid as string,
|
|
159
|
+
displayName: row.display_name as string,
|
|
160
|
+
avatarHash: (row.avatar_hash as string | null | undefined) ?? null,
|
|
161
|
+
provider: row.provider as "local" | "ipa",
|
|
162
|
+
profile: row.profile as "user" | "guest",
|
|
163
|
+
status: row.status as NotificationBatchRecipientStatus,
|
|
164
|
+
notificationId: row.notification_id as string | null,
|
|
165
|
+
error: row.error as string | null,
|
|
166
|
+
attemptCount: Number(row.attempt_count ?? 0),
|
|
167
|
+
sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
|
|
168
|
+
updatedAt: (row.updated_at as Date).toISOString(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const resolveCandidates = async (rawSelection: NotificationBatchSelection): Promise<RecipientCandidate[]> => {
|
|
172
|
+
const selection = normalizeSelection(rawSelection);
|
|
173
|
+
const userIds = selection.userIds ?? [];
|
|
174
|
+
const groupIds = selection.groupIds ?? [];
|
|
175
|
+
|
|
176
|
+
if (userIds.length === 0 && groupIds.length === 0) return [];
|
|
177
|
+
|
|
178
|
+
return await sql<RecipientCandidate[]>`
|
|
179
|
+
WITH RECURSIVE
|
|
180
|
+
selected_users(user_id) AS (
|
|
181
|
+
SELECT unnest(${toPgUuidArray(userIds)}::uuid[])
|
|
182
|
+
),
|
|
183
|
+
selected_groups(group_id) AS (
|
|
184
|
+
SELECT unnest(${toPgUuidArray(groupIds)}::uuid[])
|
|
185
|
+
),
|
|
186
|
+
group_tree(group_id) AS (
|
|
187
|
+
SELECT group_id FROM selected_groups
|
|
188
|
+
UNION
|
|
189
|
+
SELECT gg.child_group_id
|
|
190
|
+
FROM auth.group_groups_v2 gg
|
|
191
|
+
JOIN group_tree tree ON tree.group_id = gg.parent_group_id
|
|
192
|
+
),
|
|
193
|
+
candidates(user_id) AS (
|
|
194
|
+
SELECT user_id FROM selected_users
|
|
195
|
+
UNION ALL
|
|
196
|
+
SELECT ug.user_id
|
|
197
|
+
FROM auth.user_groups_v2 ug
|
|
198
|
+
JOIN group_tree tree ON tree.group_id = ug.group_id
|
|
199
|
+
),
|
|
200
|
+
candidate_counts AS (
|
|
201
|
+
SELECT user_id, COUNT(*)::int AS source_hits
|
|
202
|
+
FROM candidates
|
|
203
|
+
GROUP BY user_id
|
|
204
|
+
)
|
|
205
|
+
SELECT u.id, u.uid, u.display_name, u.mail, u.provider, u.profile, c.source_hits
|
|
206
|
+
FROM candidate_counts c
|
|
207
|
+
JOIN auth.users u ON u.id = c.user_id
|
|
208
|
+
ORDER BY u.uid
|
|
209
|
+
`;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const sendBatchEmail = async (params: {
|
|
213
|
+
recipient: string;
|
|
214
|
+
subject: string;
|
|
215
|
+
rawHtml: string;
|
|
216
|
+
sentBy?: string;
|
|
217
|
+
}): Promise<{ id: string; status: "sent" | "error"; error?: string }> => {
|
|
218
|
+
const rows = await sql<BatchRow[]>`
|
|
219
|
+
INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
|
|
220
|
+
VALUES ('email', ${params.recipient}, ${params.subject}, ${params.rawHtml}, ${params.sentBy ?? null})
|
|
221
|
+
RETURNING id
|
|
222
|
+
`;
|
|
223
|
+
const id = rows[0]!.id as string;
|
|
224
|
+
try {
|
|
225
|
+
await sendEmail(params.recipient, params.subject, { rawHtml: params.rawHtml });
|
|
226
|
+
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}::uuid`;
|
|
227
|
+
return { id, status: "sent" };
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
await sql`UPDATE notifications.messages SET error = ${message} WHERE id = ${id}::uuid`;
|
|
231
|
+
return { id, status: "error", error: message };
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const preview = async (selection: NotificationBatchSelection): Promise<NotificationBatchPreview> => {
|
|
236
|
+
const candidates = await resolveCandidates(selection);
|
|
237
|
+
return {
|
|
238
|
+
targetCount: candidates.length,
|
|
239
|
+
deliverableCount: candidates.filter((candidate) => candidate.mail).length,
|
|
240
|
+
skippedNoEmailCount: candidates.filter((candidate) => !candidate.mail).length,
|
|
241
|
+
duplicateCount: candidates.reduce((sum, candidate) => sum + Math.max(0, Number(candidate.source_hits ?? 1) - 1), 0),
|
|
242
|
+
recipientHash: recipientHash(candidates),
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const refreshBatchCounters = async (batchId: string): Promise<NotificationBatch | null> => {
|
|
247
|
+
const rows = await sql<BatchRow[]>`
|
|
248
|
+
WITH counts AS (
|
|
249
|
+
SELECT
|
|
250
|
+
COUNT(*)::int AS target_count,
|
|
251
|
+
COUNT(*) FILTER (WHERE r.recipient IS NOT NULL)::int AS deliverable_count,
|
|
252
|
+
COUNT(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
|
|
253
|
+
COUNT(*) FILTER (WHERE r.status = 'skipped')::int AS skipped_count,
|
|
254
|
+
COUNT(*) FILTER (WHERE r.status = 'error')::int AS error_count,
|
|
255
|
+
COUNT(*) FILTER (WHERE r.status IN ('pending', 'sending'))::int AS pending_count,
|
|
256
|
+
MAX(COALESCE(m.error, r.error)) FILTER (WHERE r.status = 'error') AS last_error
|
|
257
|
+
FROM notifications.batch_recipients r
|
|
258
|
+
LEFT JOIN notifications.messages m ON m.id = r.notification_id
|
|
259
|
+
WHERE r.batch_id = ${batchId}::uuid
|
|
260
|
+
)
|
|
261
|
+
UPDATE notifications.batches b
|
|
262
|
+
SET
|
|
263
|
+
target_count = counts.target_count,
|
|
264
|
+
deliverable_count = counts.deliverable_count,
|
|
265
|
+
sent_count = counts.sent_count,
|
|
266
|
+
skipped_count = counts.skipped_count,
|
|
267
|
+
error_count = counts.error_count,
|
|
268
|
+
last_error = counts.last_error,
|
|
269
|
+
status = CASE
|
|
270
|
+
WHEN b.status IN ('draft', 'ready', 'cancelled') THEN b.status
|
|
271
|
+
WHEN counts.pending_count > 0 THEN 'running'
|
|
272
|
+
WHEN counts.error_count > 0 THEN 'completed_with_errors'
|
|
273
|
+
ELSE 'completed'
|
|
274
|
+
END,
|
|
275
|
+
completed_at = CASE
|
|
276
|
+
WHEN b.status IN ('draft', 'ready', 'cancelled') THEN b.completed_at
|
|
277
|
+
WHEN counts.pending_count = 0 AND b.completed_at IS NULL THEN now()
|
|
278
|
+
ELSE b.completed_at
|
|
279
|
+
END
|
|
280
|
+
FROM counts
|
|
281
|
+
WHERE b.id = ${batchId}::uuid
|
|
282
|
+
RETURNING b.*
|
|
283
|
+
`;
|
|
284
|
+
return rows[0] ? mapBatch(rows[0]) : null;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const processBatchChunk = async (batchId: string): Promise<{ processed: number; remaining: number }> => {
|
|
288
|
+
await sql`
|
|
289
|
+
UPDATE notifications.batches
|
|
290
|
+
SET status = 'running', started_at = COALESCE(started_at, now())
|
|
291
|
+
WHERE id = ${batchId}::uuid AND status IN ('ready', 'running')
|
|
292
|
+
`;
|
|
293
|
+
|
|
294
|
+
const recipients = await sql<BatchRow[]>`
|
|
295
|
+
UPDATE notifications.batch_recipients r
|
|
296
|
+
SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
|
297
|
+
WHERE (r.batch_id, r.user_id) IN (
|
|
298
|
+
SELECT batch_id, user_id
|
|
299
|
+
FROM notifications.batch_recipients
|
|
300
|
+
WHERE batch_id = ${batchId}::uuid
|
|
301
|
+
AND recipient IS NOT NULL
|
|
302
|
+
AND (
|
|
303
|
+
status = 'pending'
|
|
304
|
+
OR (status = 'sending' AND updated_at < now() - interval '5 minutes')
|
|
305
|
+
)
|
|
306
|
+
ORDER BY updated_at ASC
|
|
307
|
+
LIMIT ${CHUNK_SIZE}
|
|
308
|
+
FOR UPDATE SKIP LOCKED
|
|
309
|
+
)
|
|
310
|
+
RETURNING r.*
|
|
311
|
+
`;
|
|
312
|
+
|
|
313
|
+
const [batchRow] = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${batchId}::uuid`;
|
|
314
|
+
if (!batchRow || batchRow.status === "cancelled") return { processed: 0, remaining: 0 };
|
|
315
|
+
const batch = mapBatch(batchRow);
|
|
316
|
+
|
|
317
|
+
for (const recipient of recipients) {
|
|
318
|
+
try {
|
|
319
|
+
const result = await sendBatchEmail({
|
|
320
|
+
recipient: recipient.recipient as string,
|
|
321
|
+
subject: batch.subject,
|
|
322
|
+
rawHtml: batch.bodyHtml,
|
|
323
|
+
sentBy: batch.finalizedBy ?? batch.createdBy ?? undefined,
|
|
324
|
+
});
|
|
325
|
+
await sql`
|
|
326
|
+
UPDATE notifications.batch_recipients
|
|
327
|
+
SET
|
|
328
|
+
status = ${result.status === "sent" ? "sent" : "error"},
|
|
329
|
+
notification_id = ${result.id}::uuid,
|
|
330
|
+
error = NULL,
|
|
331
|
+
sent_at = ${result.status === "sent" ? sql`now()` : null},
|
|
332
|
+
updated_at = now()
|
|
333
|
+
WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
|
|
334
|
+
`;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
337
|
+
log.error("Batch recipient send failed", { batchId, userId: recipient.user_id, error: message });
|
|
338
|
+
await sql`
|
|
339
|
+
UPDATE notifications.batch_recipients
|
|
340
|
+
SET status = 'error', error = ${message}, updated_at = now()
|
|
341
|
+
WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const [pendingRow] = await sql<BatchRow[]>`
|
|
347
|
+
SELECT COUNT(*)::int AS count
|
|
348
|
+
FROM notifications.batch_recipients
|
|
349
|
+
WHERE batch_id = ${batchId}::uuid
|
|
350
|
+
AND recipient IS NOT NULL
|
|
351
|
+
AND status IN ('pending', 'sending')
|
|
352
|
+
`;
|
|
353
|
+
const remaining = Number(pendingRow?.count ?? 0);
|
|
354
|
+
await refreshBatchCounters(batchId);
|
|
355
|
+
return { processed: recipients.length, remaining };
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const batchJob = lazySync((sync) => {
|
|
359
|
+
const handle = sync.job<{ batchId: string }>({
|
|
360
|
+
id: "notifications:batches",
|
|
361
|
+
owner: "core",
|
|
362
|
+
delivery: { ackWaitMs: 180_000, maxAttempts: 4, backoffMs: [1_000, 2_000, 4_000] },
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return handle;
|
|
366
|
+
});
|
|
367
|
+
let batchWorker: Worker | undefined;
|
|
368
|
+
const startBatchWorker = () =>
|
|
369
|
+
batchJob().process(
|
|
370
|
+
{
|
|
371
|
+
onError: async ({ context, error }) => {
|
|
372
|
+
if (context.attempt < 4) return { action: "retry" };
|
|
373
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
374
|
+
log.error("Notification batch job failed", { batchId: context.input.batchId, attempt: context.attempt, error: message });
|
|
375
|
+
await sql`
|
|
376
|
+
UPDATE notifications.batches
|
|
377
|
+
SET status = 'failed', completed_at = now(), last_error = ${message}
|
|
378
|
+
WHERE id = ${context.input.batchId}::uuid AND status IN ('ready', 'running')
|
|
379
|
+
`;
|
|
380
|
+
return { action: "dead_letter", reason: message };
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
async (ctx) => {
|
|
384
|
+
if (ctx.signal.aborted) return;
|
|
385
|
+
const result = await trace.withSpan(
|
|
386
|
+
{
|
|
387
|
+
name: "Notification batch delivery",
|
|
388
|
+
source: "notifications:batches",
|
|
389
|
+
appId: "core",
|
|
390
|
+
category: "job",
|
|
391
|
+
kind: "consumer",
|
|
392
|
+
spanKey: trace.syncSpanKey("job", "notifications:batches", ctx.jobId),
|
|
393
|
+
attributes: { "cloud.notification.batch_id": ctx.input.batchId },
|
|
394
|
+
},
|
|
395
|
+
() => processBatchChunk(ctx.input.batchId),
|
|
396
|
+
{ summarize: (summary) => summary },
|
|
397
|
+
);
|
|
398
|
+
await ctx.heartbeat();
|
|
399
|
+
if (result.remaining > 0) await ctx.resubmit({ delayMs: result.processed > 0 ? 0 : 60_000 });
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
const submitBatch = async (batchId: string): Promise<string> =>
|
|
403
|
+
(await batchJob().submit({ key: batchId, input: { batchId }, coalesce: true })).jobId;
|
|
404
|
+
|
|
405
|
+
export const createDraft = async (params: {
|
|
406
|
+
subject: string;
|
|
407
|
+
bodyMarkdown: string;
|
|
408
|
+
selection: NotificationBatchSelection;
|
|
409
|
+
createdBy: string;
|
|
410
|
+
}): Promise<Result<NotificationBatch>> => {
|
|
411
|
+
const subject = params.subject.trim();
|
|
412
|
+
const bodyMarkdown = params.bodyMarkdown.trim();
|
|
413
|
+
if (!subject) return fail(err.badInput("Subject is required"));
|
|
414
|
+
if (!bodyMarkdown) return fail(err.badInput("Message is required"));
|
|
415
|
+
const selection = normalizeSelection(params.selection);
|
|
416
|
+
if (!hasAudience(selection)) {
|
|
417
|
+
return fail(err.badInput("Select at least one user or group."));
|
|
418
|
+
}
|
|
419
|
+
const candidates = await resolveCandidates(selection);
|
|
420
|
+
if (!candidates.some((candidate) => candidate.mail)) {
|
|
421
|
+
return fail(err.badInput("No deliverable recipients match this selection"));
|
|
422
|
+
}
|
|
423
|
+
const hash = selectionHash(selection);
|
|
424
|
+
const bodyHtml = markdown.renderSync(bodyMarkdown);
|
|
425
|
+
const rows = await sql<BatchRow[]>`
|
|
426
|
+
INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
|
|
427
|
+
VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::jsonb, ${hash}, ${params.createdBy}::uuid)
|
|
428
|
+
RETURNING *
|
|
429
|
+
`;
|
|
430
|
+
return ok(mapBatch(rows[0]!));
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
export const get = async (id: string): Promise<NotificationBatch | null> => {
|
|
434
|
+
const rows = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${id}::uuid`;
|
|
435
|
+
return rows[0] ? mapBatch(rows[0]) : null;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
export const list = async (params?: {
|
|
439
|
+
page?: number;
|
|
440
|
+
perPage?: number;
|
|
441
|
+
status?: NotificationBatchStatus;
|
|
442
|
+
}): Promise<{ items: NotificationBatch[]; total: number; page: number; perPage: number }> => {
|
|
443
|
+
const page = Math.min(Math.max(params?.page ?? 1, 1), MAX_PAGE);
|
|
444
|
+
const perPage = Math.min(Math.max(params?.perPage ?? 50, 1), MAX_PER_PAGE);
|
|
445
|
+
const offset = (page - 1) * perPage;
|
|
446
|
+
const rows = await sql<BatchRow[]>`
|
|
447
|
+
SELECT *, COUNT(*) OVER() AS total
|
|
448
|
+
FROM notifications.batches
|
|
449
|
+
WHERE (${params?.status ?? null}::text IS NULL OR status = ${params?.status ?? null})
|
|
450
|
+
ORDER BY created_at DESC
|
|
451
|
+
LIMIT ${perPage} OFFSET ${offset}
|
|
452
|
+
`;
|
|
453
|
+
return {
|
|
454
|
+
items: rows.map(mapBatch),
|
|
455
|
+
total: Number(rows[0]?.total ?? 0),
|
|
456
|
+
page,
|
|
457
|
+
perPage,
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
export const listRecipients = async (params: {
|
|
462
|
+
batchId: string;
|
|
463
|
+
page?: number;
|
|
464
|
+
perPage?: number;
|
|
465
|
+
status?: NotificationBatchRecipientStatus;
|
|
466
|
+
}): Promise<{ items: NotificationBatchRecipient[]; total: number; page: number; perPage: number }> => {
|
|
467
|
+
const page = Math.min(Math.max(params.page ?? 1, 1), MAX_PAGE);
|
|
468
|
+
const perPage = Math.min(Math.max(params.perPage ?? 100, 1), MAX_PER_PAGE);
|
|
469
|
+
const offset = (page - 1) * perPage;
|
|
470
|
+
const rows = await sql<BatchRow[]>`
|
|
471
|
+
SELECT
|
|
472
|
+
r.batch_id,
|
|
473
|
+
r.user_id,
|
|
474
|
+
r.recipient,
|
|
475
|
+
r.uid,
|
|
476
|
+
r.display_name,
|
|
477
|
+
u.avatar_hash,
|
|
478
|
+
r.provider,
|
|
479
|
+
r.profile,
|
|
480
|
+
r.status,
|
|
481
|
+
r.notification_id,
|
|
482
|
+
COALESCE(m.error, r.error) AS error,
|
|
483
|
+
r.attempt_count,
|
|
484
|
+
r.sent_at,
|
|
485
|
+
r.updated_at,
|
|
486
|
+
COUNT(*) OVER() AS total
|
|
487
|
+
FROM notifications.batch_recipients r
|
|
488
|
+
LEFT JOIN notifications.messages m ON m.id = r.notification_id
|
|
489
|
+
LEFT JOIN auth.users u ON u.id = r.user_id
|
|
490
|
+
WHERE r.batch_id = ${params.batchId}::uuid
|
|
491
|
+
AND (${params.status ?? null}::text IS NULL OR r.status = ${params.status ?? null})
|
|
492
|
+
ORDER BY
|
|
493
|
+
CASE r.status WHEN 'error' THEN 0 WHEN 'pending' THEN 1 WHEN 'sending' THEN 2 WHEN 'skipped' THEN 3 ELSE 4 END,
|
|
494
|
+
r.uid
|
|
495
|
+
LIMIT ${perPage} OFFSET ${offset}
|
|
496
|
+
`;
|
|
497
|
+
return {
|
|
498
|
+
items: rows.map(mapRecipient),
|
|
499
|
+
total: Number(rows[0]?.total ?? 0),
|
|
500
|
+
page,
|
|
501
|
+
perPage,
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
export const finalize = async (params: {
|
|
506
|
+
id: string;
|
|
507
|
+
actorUserId: string;
|
|
508
|
+
expectedSelectionHash: string;
|
|
509
|
+
expectedDeliverableCount: number;
|
|
510
|
+
expectedRecipientHash: string;
|
|
511
|
+
}): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
|
|
512
|
+
const batch = await get(params.id);
|
|
513
|
+
if (!batch) return fail(err.notFound("Notification batch not found"));
|
|
514
|
+
if (hasLegacyAudienceSelection(batch.selection)) {
|
|
515
|
+
return fail(err.badInput("Legacy notification drafts cannot be finalized. Create a new notification batch."));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const candidates = await resolveCandidates(batch.selection);
|
|
519
|
+
const deliverableCount = candidates.filter((candidate) => candidate.mail).length;
|
|
520
|
+
const currentRecipientHash = recipientHash(candidates);
|
|
521
|
+
if (deliverableCount === 0) {
|
|
522
|
+
return fail(err.badInput("No deliverable recipients match this batch"));
|
|
523
|
+
}
|
|
524
|
+
const result = await sql.begin(async (tx): Promise<Result<NotificationBatch>> => {
|
|
525
|
+
const rows = await tx<BatchRow[]>`
|
|
526
|
+
SELECT * FROM notifications.batches WHERE id = ${params.id}::uuid FOR UPDATE
|
|
527
|
+
`;
|
|
528
|
+
const batchRow = rows[0];
|
|
529
|
+
if (!batchRow) return fail(err.notFound("Notification batch not found"));
|
|
530
|
+
if (batchRow.status !== "draft") return fail(err.conflict("Notification batch has already been finalized"));
|
|
531
|
+
if (batchRow.selection_hash !== params.expectedSelectionHash)
|
|
532
|
+
return fail(err.conflict("Notification selection changed. Refresh the preview."));
|
|
533
|
+
if (deliverableCount !== params.expectedDeliverableCount) {
|
|
534
|
+
return fail(err.conflict("Recipient count changed. Refresh the preview before sending."));
|
|
535
|
+
}
|
|
536
|
+
if (currentRecipientHash !== params.expectedRecipientHash) {
|
|
537
|
+
return fail(err.conflict("Recipient list changed. Refresh the preview before sending."));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
await tx`
|
|
541
|
+
INSERT INTO notifications.batch_recipients (
|
|
542
|
+
batch_id, user_id, recipient, uid, display_name, provider, profile, status, error
|
|
543
|
+
)
|
|
544
|
+
SELECT
|
|
545
|
+
${params.id}::uuid,
|
|
546
|
+
row.user_id,
|
|
547
|
+
NULLIF(row.recipient, ''),
|
|
548
|
+
row.uid,
|
|
549
|
+
row.display_name,
|
|
550
|
+
row.provider,
|
|
551
|
+
row.profile,
|
|
552
|
+
row.status,
|
|
553
|
+
NULLIF(row.error, '')
|
|
554
|
+
FROM unnest(
|
|
555
|
+
${toPgUuidArray(candidates.map((candidate) => candidate.id))}::uuid[],
|
|
556
|
+
${toPgTextArray(candidates.map((candidate) => candidate.mail ?? ""))}::text[],
|
|
557
|
+
${toPgTextArray(candidates.map((candidate) => candidate.uid))}::text[],
|
|
558
|
+
${toPgTextArray(candidates.map((candidate) => candidate.display_name ?? ""))}::text[],
|
|
559
|
+
${toPgTextArray(candidates.map((candidate) => candidate.provider))}::text[],
|
|
560
|
+
${toPgTextArray(candidates.map((candidate) => candidate.profile))}::text[],
|
|
561
|
+
${toPgTextArray(candidates.map((candidate) => (candidate.mail ? "pending" : "skipped")))}::text[],
|
|
562
|
+
${toPgTextArray(candidates.map((candidate) => (candidate.mail ? "" : "User has no email address")))}::text[]
|
|
563
|
+
) AS row(user_id, recipient, uid, display_name, provider, profile, status, error)
|
|
564
|
+
ON CONFLICT (batch_id, user_id) DO NOTHING
|
|
565
|
+
`;
|
|
566
|
+
|
|
567
|
+
const updated = await tx<BatchRow[]>`
|
|
568
|
+
UPDATE notifications.batches
|
|
569
|
+
SET
|
|
570
|
+
status = 'ready',
|
|
571
|
+
finalized_by = ${params.actorUserId}::uuid,
|
|
572
|
+
finalized_at = now(),
|
|
573
|
+
target_count = ${candidates.length},
|
|
574
|
+
deliverable_count = ${deliverableCount},
|
|
575
|
+
skipped_count = ${candidates.length - deliverableCount}
|
|
576
|
+
WHERE id = ${params.id}::uuid
|
|
577
|
+
RETURNING *
|
|
578
|
+
`;
|
|
579
|
+
return ok(mapBatch(updated[0]!));
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (!result.ok) return result;
|
|
583
|
+
const jobId = await submitBatch(params.id);
|
|
584
|
+
return ok({ batch: result.data, jobId });
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export const retryFailed = async (params: { id: string }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
|
|
588
|
+
const batch = await get(params.id);
|
|
589
|
+
if (!batch) return fail(err.notFound("Notification batch not found"));
|
|
590
|
+
if (batch.status === "draft" || batch.status === "cancelled") {
|
|
591
|
+
return fail(err.conflict("Only finalized notification batches can retry recipients"));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const updated = await sql<BatchRow[]>`
|
|
595
|
+
UPDATE notifications.batch_recipients
|
|
596
|
+
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
597
|
+
WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
|
|
598
|
+
RETURNING user_id
|
|
599
|
+
`;
|
|
600
|
+
if (updated.length === 0) {
|
|
601
|
+
return fail(err.conflict("No failed deliverable recipients can be retried"));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
await sql`
|
|
605
|
+
UPDATE notifications.batches
|
|
606
|
+
SET status = 'ready', completed_at = NULL, last_error = NULL
|
|
607
|
+
WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
|
|
608
|
+
`;
|
|
609
|
+
const refreshed = await refreshBatchCounters(params.id);
|
|
610
|
+
// A completing worker may already have sampled zero remaining recipients.
|
|
611
|
+
// Explicit retries need their own wakeup even while that worker holds its key.
|
|
612
|
+
const { jobId } = await batchJob().submit({ key: `${params.id}:retry:${crypto.randomUUID()}`, input: { batchId: params.id } });
|
|
613
|
+
return ok({ batch: refreshed ?? batch, jobId });
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
export const retryRecipient = async (params: {
|
|
617
|
+
id: string;
|
|
618
|
+
userId: string;
|
|
619
|
+
}): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
|
|
620
|
+
const batch = await get(params.id);
|
|
621
|
+
if (!batch) return fail(err.notFound("Notification batch not found"));
|
|
622
|
+
if (batch.status === "draft" || batch.status === "cancelled") {
|
|
623
|
+
return fail(err.conflict("Only finalized notification batches can retry recipients"));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const updated = await sql<BatchRow[]>`
|
|
627
|
+
UPDATE notifications.batch_recipients
|
|
628
|
+
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
629
|
+
WHERE batch_id = ${params.id}::uuid
|
|
630
|
+
AND user_id = ${params.userId}::uuid
|
|
631
|
+
AND status = 'error'
|
|
632
|
+
AND recipient IS NOT NULL
|
|
633
|
+
RETURNING *
|
|
634
|
+
`;
|
|
635
|
+
if (!updated[0]) {
|
|
636
|
+
const existing = await sql<BatchRow[]>`
|
|
637
|
+
SELECT status, recipient
|
|
638
|
+
FROM notifications.batch_recipients
|
|
639
|
+
WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
|
|
640
|
+
`;
|
|
641
|
+
if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
|
|
642
|
+
return fail(err.conflict("Only failed deliverable recipients can be retried"));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
await sql`
|
|
646
|
+
UPDATE notifications.batches
|
|
647
|
+
SET status = 'ready', completed_at = NULL, last_error = NULL
|
|
648
|
+
WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
|
|
649
|
+
`;
|
|
650
|
+
const refreshed = await refreshBatchCounters(params.id);
|
|
651
|
+
const { jobId } = await batchJob().submit({
|
|
652
|
+
key: `${params.id}:retry:${crypto.randomUUID()}`,
|
|
653
|
+
input: { batchId: params.id },
|
|
654
|
+
});
|
|
655
|
+
return ok({ batch: refreshed ?? batch, jobId });
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
export const removeDraft = async (params: { id: string }): Promise<Result<{ id: string }>> => {
|
|
659
|
+
const rows = await sql<BatchRow[]>`
|
|
660
|
+
DELETE FROM notifications.batches
|
|
661
|
+
WHERE id = ${params.id}::uuid AND status = 'draft'
|
|
662
|
+
RETURNING id
|
|
663
|
+
`;
|
|
664
|
+
if (rows[0]) return ok({ id: rows[0].id as string });
|
|
665
|
+
|
|
666
|
+
const batch = await get(params.id);
|
|
667
|
+
if (!batch) return fail(err.notFound("Notification batch not found"));
|
|
668
|
+
return fail(err.conflict("Only draft notification batches can be deleted"));
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
export const start = async (): Promise<void> => {
|
|
672
|
+
if (batchWorker) return;
|
|
673
|
+
batchWorker = await startBatchWorker();
|
|
674
|
+
try {
|
|
675
|
+
// Postgres owns the batch state; recover unfinished work across cutover and
|
|
676
|
+
// restarts with keyset pages rather than an unbounded startup scan.
|
|
677
|
+
let after: string | null = null;
|
|
678
|
+
while (true) {
|
|
679
|
+
const pending: { id: string }[] = await sql`
|
|
680
|
+
SELECT id FROM notifications.batches
|
|
681
|
+
WHERE status IN ('ready', 'running') AND (${after}::uuid IS NULL OR id > ${after}::uuid)
|
|
682
|
+
ORDER BY id LIMIT ${CHUNK_SIZE}
|
|
683
|
+
`;
|
|
684
|
+
for (const batch of pending) await submitBatch(batch.id);
|
|
685
|
+
if (pending.length < CHUNK_SIZE) break;
|
|
686
|
+
after = pending[pending.length - 1]!.id;
|
|
687
|
+
}
|
|
688
|
+
} catch (error) {
|
|
689
|
+
batchWorker.stop();
|
|
690
|
+
await batchWorker.drain();
|
|
691
|
+
batchWorker = undefined;
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
export const stop = async (): Promise<void> => {
|
|
697
|
+
batchWorker?.stop();
|
|
698
|
+
await batchWorker?.drain();
|
|
699
|
+
batchWorker = undefined;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
export const notificationBatches = {
|
|
703
|
+
createDraft,
|
|
704
|
+
preview,
|
|
705
|
+
get,
|
|
706
|
+
list,
|
|
707
|
+
listRecipients,
|
|
708
|
+
finalize,
|
|
709
|
+
retryFailed,
|
|
710
|
+
retryRecipient,
|
|
711
|
+
removeDraft,
|
|
712
|
+
start,
|
|
713
|
+
stop,
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
export const __notificationBatchTest = {
|
|
717
|
+
normalizeSelection,
|
|
718
|
+
hasLegacyAudienceSelection,
|
|
719
|
+
selectionHash,
|
|
720
|
+
recipientHash,
|
|
721
|
+
resolveCandidates,
|
|
722
|
+
};
|