create-windy 0.2.33 → 0.3.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.
Files changed (284) hide show
  1. package/dist/cli.js +173 -17
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +9 -0
  6. package/template/README.md +3 -1
  7. package/template/apps/agent-server/Dockerfile +1 -0
  8. package/template/apps/agent-server/README.md +28 -5
  9. package/template/apps/agent-server/adk_web_agents/__init__.py +1 -0
  10. package/template/apps/agent-server/adk_web_agents/safe_debug/__init__.py +3 -0
  11. package/template/apps/agent-server/adk_web_agents/safe_debug/agent.py +40 -0
  12. package/template/apps/agent-server/pyproject.toml +1 -1
  13. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +191 -16
  14. package/template/apps/agent-server/src/southwind_agent_server/adk_failure.py +13 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +225 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +76 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/app.py +63 -3
  18. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
  21. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
  22. package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
  23. package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
  24. package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
  25. package/template/apps/agent-server/tests/test_adk_adapter.py +305 -0
  26. package/template/apps/agent-server/tests/test_adk_web_agent.py +12 -0
  27. package/template/apps/agent-server/tests/test_api.py +16 -1
  28. package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
  29. package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
  30. package/template/apps/server/package.json +4 -1
  31. package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
  32. package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
  33. package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
  34. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  35. package/template/apps/server/src/agent/run-contracts.ts +2 -0
  36. package/template/apps/server/src/agent/run-facade.test.ts +52 -2
  37. package/template/apps/server/src/agent/run-facade.ts +38 -9
  38. package/template/apps/server/src/agent/tool-registry.ts +2 -6
  39. package/template/apps/server/src/application-services.ts +50 -0
  40. package/template/apps/server/src/audit/drizzle-log-store.ts +25 -0
  41. package/template/apps/server/src/audit/search-summary.ts +2 -6
  42. package/template/apps/server/src/auth/credential-restriction.ts +1 -0
  43. package/template/apps/server/src/auth/password.ts +30 -21
  44. package/template/apps/server/src/auth/routes.test.ts +44 -3
  45. package/template/apps/server/src/auth/routes.ts +6 -1
  46. package/template/apps/server/src/auth/runtime.ts +4 -1
  47. package/template/apps/server/src/auth/service.ts +25 -7
  48. package/template/apps/server/src/configuration/bootstrap.ts +20 -1
  49. package/template/apps/server/src/configuration/definition.test.ts +71 -0
  50. package/template/apps/server/src/configuration/definition.ts +242 -12
  51. package/template/apps/server/src/configuration/drizzle-repository.ts +36 -2
  52. package/template/apps/server/src/configuration/repository.ts +27 -2
  53. package/template/apps/server/src/configuration/routes.ts +4 -1
  54. package/template/apps/server/src/configuration/service.test.ts +100 -0
  55. package/template/apps/server/src/configuration/service.ts +50 -10
  56. package/template/apps/server/src/data-access/scoped-repository.integration.test.ts +3 -2
  57. package/template/apps/server/src/data-governance/export/service.ts +2 -6
  58. package/template/apps/server/src/example-modules.ts +4 -1
  59. package/template/apps/server/src/guards.ts +18 -5
  60. package/template/apps/server/src/http/request-source.test.ts +85 -0
  61. package/template/apps/server/src/http/request-source.ts +90 -0
  62. package/template/apps/server/src/index.ts +45 -45
  63. package/template/apps/server/src/license/routes.ts +2 -0
  64. package/template/apps/server/src/license/runtime-service.ts +10 -11
  65. package/template/apps/server/src/module-bootstrap.ts +2 -2
  66. package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
  67. package/template/apps/server/src/module-host/initialize.ts +30 -44
  68. package/template/apps/server/src/module-host/request-context.ts +29 -23
  69. package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
  70. package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
  71. package/template/apps/server/src/module-host/route-declaration.ts +45 -0
  72. package/template/apps/server/src/module-host/route-installer.ts +27 -3
  73. package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
  74. package/template/apps/server/src/module-host/storage-port.ts +134 -1
  75. package/template/apps/server/src/module-host/storage-scope.ts +89 -0
  76. package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
  77. package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
  78. package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
  79. package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
  80. package/template/apps/server/src/module-host/user-directory-port.ts +139 -0
  81. package/template/apps/server/src/module-host.ts +4 -0
  82. package/template/apps/server/src/notification/channel-adapter.ts +46 -0
  83. package/template/apps/server/src/notification/delivery-policy.test.ts +59 -0
  84. package/template/apps/server/src/notification/delivery-policy.ts +37 -0
  85. package/template/apps/server/src/notification/delivery-repository.ts +198 -0
  86. package/template/apps/server/src/notification/delivery-worker.test.ts +87 -0
  87. package/template/apps/server/src/notification/delivery-worker.ts +88 -0
  88. package/template/apps/server/src/notification/drizzle-management.ts +180 -0
  89. package/template/apps/server/src/notification/drizzle-query.ts +160 -0
  90. package/template/apps/server/src/notification/drizzle-repository.integration.test.ts +301 -0
  91. package/template/apps/server/src/notification/drizzle-repository.ts +156 -90
  92. package/template/apps/server/src/notification/extension-routes.test.ts +119 -0
  93. package/template/apps/server/src/notification/extension-routes.ts +111 -0
  94. package/template/apps/server/src/notification/memory-delivery-repository.ts +137 -0
  95. package/template/apps/server/src/notification/memory-support.ts +72 -0
  96. package/template/apps/server/src/notification/preference-repository.ts +95 -0
  97. package/template/apps/server/src/notification/recipient-directory.ts +104 -0
  98. package/template/apps/server/src/notification/repository.test.ts +171 -0
  99. package/template/apps/server/src/notification/repository.ts +179 -32
  100. package/template/apps/server/src/notification/revision-routes.test.ts +186 -0
  101. package/template/apps/server/src/notification/route-support.ts +225 -0
  102. package/template/apps/server/src/notification/routes.test.ts +182 -0
  103. package/template/apps/server/src/notification/routes.ts +225 -132
  104. package/template/apps/server/src/notification/service.test.ts +104 -0
  105. package/template/apps/server/src/notification/service.ts +140 -0
  106. package/template/apps/server/src/notification/smtp-adapter.test.ts +65 -0
  107. package/template/apps/server/src/notification/smtp-adapter.ts +68 -0
  108. package/template/apps/server/src/persistence.integration.test.ts +41 -2
  109. package/template/apps/server/src/route-guards.ts +1 -0
  110. package/template/apps/server/src/runtime-audit-list.test.ts +39 -0
  111. package/template/apps/server/src/runtime-development.ts +2 -0
  112. package/template/apps/server/src/runtime.test.ts +23 -9
  113. package/template/apps/server/src/runtime.ts +39 -2
  114. package/template/apps/server/src/scheduler/audit.ts +2 -11
  115. package/template/apps/server/src/scheduler/definitions.test.ts +6 -0
  116. package/template/apps/server/src/scheduler/definitions.ts +7 -0
  117. package/template/apps/server/src/scheduler/recovery-service.ts +2 -11
  118. package/template/apps/server/src/scheduler/routes.ts +2 -7
  119. package/template/apps/server/src/search/opensearch-client.test.ts +45 -0
  120. package/template/apps/server/src/search/opensearch-client.ts +145 -0
  121. package/template/apps/server/src/search/service.ts +2 -6
  122. package/template/apps/server/src/settings/routes.ts +15 -3
  123. package/template/apps/server/src/settings/runtime.ts +10 -1
  124. package/template/apps/server/src/storage/runtime.ts +13 -0
  125. package/template/apps/server/src/system/audit-query.ts +33 -4
  126. package/template/apps/server/src/system/audit-routes.test.ts +58 -4
  127. package/template/apps/server/src/system/audit-routes.ts +6 -5
  128. package/template/apps/server/src/system/built-in-roles.ts +1 -0
  129. package/template/apps/server/src/system/department-organization.ts +320 -0
  130. package/template/apps/server/src/system/department-routes.test.ts +207 -48
  131. package/template/apps/server/src/system/department-routes.ts +114 -33
  132. package/template/apps/server/src/system/drizzle-repository.ts +15 -7
  133. package/template/apps/server/src/system/drizzle-scoped-repository.ts +5 -3
  134. package/template/apps/server/src/system/drizzle-system.ts +37 -9
  135. package/template/apps/server/src/system/drizzle-transaction-context.ts +14 -0
  136. package/template/apps/server/src/system/identity-route-config.ts +7 -2
  137. package/template/apps/server/src/system/mutation-runner.ts +23 -0
  138. package/template/apps/server/src/system/organization-postgres.integration.test.ts +176 -0
  139. package/template/apps/server/src/system/resource-access-response.ts +12 -6
  140. package/template/apps/server/src/system/role-department-scope-policy.ts +55 -0
  141. package/template/apps/server/src/system/route-helpers.ts +2 -5
  142. package/template/apps/server/src/system/route-types.ts +12 -3
  143. package/template/apps/server/src/system/routes-validation.test.ts +26 -0
  144. package/template/apps/server/src/system/routes.test.ts +10 -2
  145. package/template/apps/server/src/system/routes.ts +70 -36
  146. package/template/apps/server/src/system/seed.ts +7 -0
  147. package/template/apps/server/src/work-order/data-scope.integration.test.ts +9 -2
  148. package/template/apps/server/src/work-order/opensearch-index.test.ts +97 -0
  149. package/template/apps/server/src/work-order/opensearch-index.ts +313 -0
  150. package/template/apps/server/src/work-order/search-provider.ts +60 -1
  151. package/template/apps/server/src/work-order/service.ts +42 -10
  152. package/template/apps/web/src/app/error-pages.ts +9 -0
  153. package/template/apps/web/src/components/auth/PasswordChangeForm.vue +16 -11
  154. package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +14 -0
  155. package/template/apps/web/src/components/common/FormDialog.vue +16 -2
  156. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +20 -1
  157. package/template/apps/web/src/composables/useGlobalSearch.ts +68 -0
  158. package/template/apps/web/src/composables/usePlatformSettings.ts +8 -7
  159. package/template/apps/web/src/layout/GlobalSearchResults.vue +58 -0
  160. package/template/apps/web/src/layout/NavigationSearchDialog.vue +42 -6
  161. package/template/apps/web/src/layout/NavigationSearchDialog.webtest.ts +71 -0
  162. package/template/apps/web/src/layout/NotificationMenu.vue +73 -31
  163. package/template/apps/web/src/layout/NotificationMenu.webtest.ts +44 -1
  164. package/template/apps/web/src/layout/NotificationMenuItem.vue +61 -0
  165. package/template/apps/web/src/layout/NotificationPreferencesDialog.vue +181 -0
  166. package/template/apps/web/src/layout/NotificationPreferencesDialog.webtest.ts +80 -0
  167. package/template/apps/web/src/lib/date-time.ts +22 -10
  168. package/template/apps/web/src/lib/date-time.webtest.ts +9 -1
  169. package/template/apps/web/src/main.ts +14 -1
  170. package/template/apps/web/src/pages/auth/ChangePasswordPage.vue +16 -1
  171. package/template/apps/web/src/pages/configuration/components/ModuleConfigVersionList.vue +1 -0
  172. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationEditor.vue +10 -0
  173. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationWorkbench.vue +37 -6
  174. package/template/apps/web/src/pages/errors/PlatformErrorPage.vue +115 -0
  175. package/template/apps/web/src/pages/errors/PlatformErrorPage.webtest.ts +44 -0
  176. package/template/apps/web/src/pages/errors/error-page-extension.ts +26 -0
  177. package/template/apps/web/src/pages/system/SystemNotificationsPage.vue +136 -68
  178. package/template/apps/web/src/pages/system/SystemNotificationsPage.webtest.ts +224 -12
  179. package/template/apps/web/src/pages/system/audit-log-presenter.ts +86 -0
  180. package/template/apps/web/src/pages/system/audit-time-range.ts +28 -0
  181. package/template/apps/web/src/pages/system/components/AuditActionCell.vue +10 -69
  182. package/template/apps/web/src/pages/system/components/AuditActionCell.webtest.ts +20 -27
  183. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.vue +134 -0
  184. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.webtest.ts +76 -0
  185. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +116 -52
  186. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +52 -15
  187. package/template/apps/web/src/pages/system/components/NotificationHistoryDialog.vue +129 -0
  188. package/template/apps/web/src/pages/system/components/NotificationManagementCard.vue +91 -0
  189. package/template/apps/web/src/pages/system/components/NotificationPublishDialog.vue +121 -0
  190. package/template/apps/web/src/pages/system/components/NotificationPublishForm.vue +162 -1
  191. package/template/apps/web/src/pages/system/components/SystemResourceActions.vue +68 -0
  192. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +74 -0
  193. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +3 -4
  194. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +91 -98
  195. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +68 -0
  196. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +20 -1
  197. package/template/apps/web/src/pages/system/components/SystemResourceTable.webtest.ts +28 -7
  198. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +210 -0
  199. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +112 -0
  200. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +17 -4
  201. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +79 -0
  202. package/template/apps/web/src/pages/system/composables/useNotificationPermissions.ts +14 -0
  203. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +2 -0
  204. package/template/apps/web/src/pages/system/composables/useSystemResource.webtest.ts +15 -2
  205. package/template/apps/web/src/pages/system/resource-config.ts +12 -4
  206. package/template/apps/web/src/router/error-navigation.ts +50 -0
  207. package/template/apps/web/src/router/error-navigation.webtest.ts +106 -0
  208. package/template/apps/web/src/router/index.ts +71 -0
  209. package/template/apps/web/src/router/index.webtest.ts +29 -0
  210. package/template/apps/web/src/router/manifest-routes.webtest.ts +4 -0
  211. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  212. package/template/apps/web/src/services/auth-api.ts +5 -0
  213. package/template/apps/web/src/services/http.ts +43 -6
  214. package/template/apps/web/src/services/http.webtest.ts +70 -0
  215. package/template/apps/web/src/services/notification-api.ts +85 -2
  216. package/template/apps/web/src/services/notification-api.webtest.ts +110 -0
  217. package/template/apps/web/src/services/search-api.ts +39 -0
  218. package/template/apps/web/src/services/system-api.ts +18 -0
  219. package/template/apps/web/vitest.config.ts +1 -0
  220. package/template/docker-compose.yml +68 -0
  221. package/template/docs/architecture/ai-egress.md +49 -7
  222. package/template/docs/architecture/ai-runtime.md +34 -6
  223. package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
  224. package/template/docs/architecture/object-storage.md +22 -2
  225. package/template/docs/architecture/search-provider-protocol.md +38 -2
  226. package/template/docs/development/custom-error-pages.md +78 -0
  227. package/template/docs/development/custom-login-page.md +3 -3
  228. package/template/docs/platform/agent-runtime.md +76 -7
  229. package/template/docs/platform/audit-reliability.md +46 -2
  230. package/template/docs/platform/business-user-directory.md +82 -0
  231. package/template/docs/platform/module-configuration.md +13 -4
  232. package/template/docs/platform/notifications.md +67 -11
  233. package/template/docs/platform/organization-membership.md +17 -3
  234. package/template/docs/platform/system-crud-api.md +16 -0
  235. package/template/docs/platform/web-system-crud.md +6 -2
  236. package/template/package.json +4 -0
  237. package/template/packages/config/index.test.ts +45 -0
  238. package/template/packages/config/package.json +1 -1
  239. package/template/packages/config/src/platform.ts +169 -48
  240. package/template/packages/database/drizzle/0035_material_nightshade.sql +10 -0
  241. package/template/packages/database/drizzle/0036_hesitant_speed.sql +35 -0
  242. package/template/packages/database/drizzle/0037_absent_nick_fury.sql +2 -0
  243. package/template/packages/database/drizzle/0038_uneven_wasp.sql +38 -0
  244. package/template/packages/database/drizzle/meta/0035_snapshot.json +7331 -0
  245. package/template/packages/database/drizzle/meta/0036_snapshot.json +7436 -0
  246. package/template/packages/database/drizzle/meta/0037_snapshot.json +7457 -0
  247. package/template/packages/database/drizzle/meta/0038_snapshot.json +7749 -0
  248. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  249. package/template/packages/database/package.json +1 -1
  250. package/template/packages/database/src/schema/identity.ts +7 -0
  251. package/template/packages/database/src/schema/notifications.ts +135 -0
  252. package/template/packages/database/src/schema-notification-delivery.test.ts +54 -0
  253. package/template/packages/database/src/schema-workflow-notification.test.ts +40 -1
  254. package/template/packages/modules/package.json +1 -1
  255. package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
  256. package/template/packages/modules/src/ai-operation-composition.ts +115 -0
  257. package/template/packages/modules/src/manifest.ts +26 -0
  258. package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
  259. package/template/packages/modules/src/system-api-permissions.ts +54 -0
  260. package/template/packages/modules/src/system-audit-actions.ts +15 -0
  261. package/template/packages/modules/src/system-features.ts +6 -1
  262. package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
  263. package/template/packages/modules/src/system-module-catalog.ts +28 -4
  264. package/template/packages/modules/src/system-modules.test.ts +13 -1
  265. package/template/packages/modules/src/system-modules.ts +12 -0
  266. package/template/packages/modules/src/system-permissions.ts +11 -5
  267. package/template/packages/server-sdk/index.ts +11 -0
  268. package/template/packages/server-sdk/package.json +3 -2
  269. package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
  270. package/template/packages/server-sdk/src/module-host.ts +8 -0
  271. package/template/packages/server-sdk/src/request-context.ts +2 -0
  272. package/template/packages/server-sdk/src/storage-port.ts +66 -0
  273. package/template/packages/server-sdk/src/tool-registration.ts +6 -0
  274. package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
  275. package/template/packages/shared/package.json +1 -1
  276. package/template/packages/shared/src/audit.ts +25 -0
  277. package/template/packages/shared/src/module-configuration.ts +5 -0
  278. package/template/packages/shared/src/notification.ts +92 -1
  279. package/template/packages/shared/src/password.ts +7 -2
  280. package/template/packages/shared/src/platform-settings.ts +2 -0
  281. package/template/packages/storage/package.json +1 -1
  282. package/template/packages/storage/src/upload-service.test.ts +16 -0
  283. package/template/packages/storage/src/upload-service.ts +5 -1
  284. package/template/scripts/notification-postgres-regression.ts +27 -0
@@ -1,27 +1,25 @@
1
- import { randomUUIDv7 } from "bun";
2
- import type {
3
- AuditActionType,
4
- FeatureFlagDefinition,
5
- HttpMethod,
6
- PublishNotificationInput,
7
- } from "@southwind-ai/shared";
8
- import type { RequestGuardContext } from "../guards.js";
1
+ import type { FeatureFlagDefinition, HttpMethod } from "@southwind-ai/shared";
9
2
  import { evaluateRouteGuards, findSystemFeature } from "../route-guards.js";
10
3
  import type { createServerRuntime } from "../runtime.js";
11
4
  import type { NotificationRepository } from "./repository.js";
5
+ import type { NotificationService } from "./service.js";
6
+ import { registerNotificationExtensionRoutes } from "./extension-routes.js";
12
7
  import { parsePagination } from "../pagination.js";
8
+ import {
9
+ notificationEvent,
10
+ notificationNotFound,
11
+ parseInboxFilters,
12
+ parsePublishInput,
13
+ parseUpdateInput,
14
+ type NotificationRouteContext,
15
+ } from "./route-support.js";
13
16
 
14
17
  type ServerRuntime = ReturnType<typeof createServerRuntime>;
15
- type RouteContext = {
16
- body?: unknown;
17
- guardContext: RequestGuardContext;
18
- params: Record<string, string | undefined>;
19
- query: Record<string, string | undefined>;
20
- };
21
18
 
22
- interface NotificationRouteApp {
19
+ export interface NotificationRouteApp {
23
20
  get(path: string, handler: (context: unknown) => unknown): unknown;
24
21
  post(path: string, handler: (context: unknown) => unknown): unknown;
22
+ put(path: string, handler: (context: unknown) => unknown): unknown;
25
23
  delete(path: string, handler: (context: unknown) => unknown): unknown;
26
24
  }
27
25
 
@@ -30,86 +28,243 @@ export function registerNotificationRoutes(
30
28
  repository: NotificationRepository,
31
29
  runtime: ServerRuntime,
32
30
  features: FeatureFlagDefinition[] = [],
31
+ service?: NotificationService,
33
32
  ) {
34
33
  const feature = findSystemFeature(features, "notification");
35
34
 
36
35
  app.get("/notifications", (raw) =>
37
- guarded(raw, "GET", "read", feature, runtime, async (context) => {
38
- const pagination = parsePagination(context.query);
39
- if (pagination instanceof Response) return pagination;
40
- return repository.listInbox(
41
- context.guardContext.actor,
42
- pagination.page,
43
- pagination.pageSize,
44
- );
45
- }),
36
+ guarded(
37
+ raw,
38
+ "GET",
39
+ "/api/notifications",
40
+ "read",
41
+ feature,
42
+ runtime,
43
+ async (context) => {
44
+ const pagination = parsePagination(context.query);
45
+ if (pagination instanceof Response) return pagination;
46
+ const filters = parseInboxFilters(context.query);
47
+ if (filters instanceof Response) return filters;
48
+ return repository.listInbox(
49
+ context.guardContext.actor,
50
+ pagination.page,
51
+ pagination.pageSize,
52
+ filters,
53
+ );
54
+ },
55
+ ),
46
56
  );
47
57
  app.post("/notifications/:id/read", (raw) =>
48
- guarded(raw, "POST", "read", feature, runtime, async (context) => {
49
- const id = context.params.id || "";
50
- const result = await repository.markRead(id, context.guardContext.actor);
51
- if (!result) return notFound(id);
52
- if (!result.alreadyRead) {
53
- runtime.auditSink.recordEvent(event(context, "notification.read", id));
54
- }
55
- return result;
56
- }),
58
+ guarded(
59
+ raw,
60
+ "POST",
61
+ "/api/notifications/:id/read",
62
+ "read",
63
+ feature,
64
+ runtime,
65
+ async (context) => {
66
+ const id = context.params.id || "";
67
+ const result = await repository.markRead(
68
+ id,
69
+ context.guardContext.actor,
70
+ );
71
+ if (!result) return notificationNotFound(id);
72
+ if (!result.alreadyRead) {
73
+ runtime.auditSink.recordEvent(
74
+ notificationEvent(context, "notification.read", id),
75
+ );
76
+ }
77
+ return result;
78
+ },
79
+ ),
80
+ );
81
+ app.put("/notifications/:id/favorite", (raw) =>
82
+ guarded(
83
+ raw,
84
+ "PUT",
85
+ "/api/notifications/:id/favorite",
86
+ "read",
87
+ feature,
88
+ runtime,
89
+ async (context) => {
90
+ const id = context.params.id || "";
91
+ const result = await repository.setFavorite(
92
+ id,
93
+ true,
94
+ context.guardContext.actor,
95
+ );
96
+ if (!result) return notificationNotFound(id);
97
+ if (result.changed) {
98
+ runtime.auditSink.recordEvent(
99
+ notificationEvent(context, "notification.favorite", id),
100
+ );
101
+ }
102
+ return result;
103
+ },
104
+ ),
105
+ );
106
+ app.delete("/notifications/:id/favorite", (raw) =>
107
+ guarded(
108
+ raw,
109
+ "DELETE",
110
+ "/api/notifications/:id/favorite",
111
+ "read",
112
+ feature,
113
+ runtime,
114
+ async (context) => {
115
+ const id = context.params.id || "";
116
+ const result = await repository.setFavorite(
117
+ id,
118
+ false,
119
+ context.guardContext.actor,
120
+ );
121
+ if (!result) return notificationNotFound(id);
122
+ if (result.changed) {
123
+ runtime.auditSink.recordEvent(
124
+ notificationEvent(context, "notification.unfavorite", id),
125
+ );
126
+ }
127
+ return result;
128
+ },
129
+ ),
57
130
  );
58
131
  app.get("/system/notifications", (raw) =>
59
- guarded(raw, "GET", "manage", feature, runtime, async (context) => {
60
- const pagination = parsePagination(context.query);
61
- if (pagination instanceof Response) return pagination;
62
- return repository.listManaged(
63
- pagination.page,
64
- pagination.pageSize,
65
- context.query.q,
66
- );
67
- }),
132
+ guarded(
133
+ raw,
134
+ "GET",
135
+ "/api/system/notifications",
136
+ "manage",
137
+ feature,
138
+ runtime,
139
+ async (context) => {
140
+ const pagination = parsePagination(context.query);
141
+ if (pagination instanceof Response) return pagination;
142
+ return repository.listManaged(
143
+ pagination.page,
144
+ pagination.pageSize,
145
+ context.query.q,
146
+ );
147
+ },
148
+ ),
149
+ );
150
+ app.get("/system/notifications/:id/revisions", (raw) =>
151
+ guarded(
152
+ raw,
153
+ "GET",
154
+ "/api/system/notifications/:id/revisions",
155
+ "manage",
156
+ feature,
157
+ runtime,
158
+ async (context) => {
159
+ const pagination = parsePagination(context.query);
160
+ if (pagination instanceof Response) return pagination;
161
+ const id = context.params.id || "";
162
+ const revisions = await repository.listRevisions(
163
+ id,
164
+ pagination.page,
165
+ pagination.pageSize,
166
+ );
167
+ return revisions || notificationNotFound(id);
168
+ },
169
+ ),
68
170
  );
69
171
  app.post("/system/notifications", (raw) =>
70
- guarded(raw, "POST", "manage", feature, runtime, async (context) => {
71
- const input = parsePublishInput(context.body);
72
- if (input instanceof Response) return input;
73
- const notification = await repository.publish(
74
- input,
75
- context.guardContext.actor,
76
- );
77
- await runtime.auditSink.recordCriticalEvent(
78
- event(context, "notification.publish", notification.id),
79
- );
80
- return notification;
81
- }),
172
+ guarded(
173
+ raw,
174
+ "POST",
175
+ "/api/system/notifications",
176
+ "manage",
177
+ feature,
178
+ runtime,
179
+ async (context) => {
180
+ const input = parsePublishInput(context.body);
181
+ if (input instanceof Response) return input;
182
+ const notification = await (service || repository).publish(
183
+ input,
184
+ context.guardContext.actor,
185
+ );
186
+ await runtime.auditSink.recordCriticalEvent(
187
+ notificationEvent(
188
+ context,
189
+ notification.status === "scheduled"
190
+ ? "notification.schedule"
191
+ : "notification.publish",
192
+ notification.id,
193
+ ),
194
+ );
195
+ return notification;
196
+ },
197
+ ),
198
+ );
199
+ app.put("/system/notifications/:id", (raw) =>
200
+ guarded(
201
+ raw,
202
+ "PUT",
203
+ "/api/system/notifications/:id",
204
+ "update",
205
+ feature,
206
+ runtime,
207
+ async (context) => {
208
+ const input = parseUpdateInput(context.body);
209
+ if (input instanceof Response) return input;
210
+ const id = context.params.id || "";
211
+ const result = await repository.update(
212
+ id,
213
+ input,
214
+ context.guardContext.actor,
215
+ );
216
+ if (!result) return notificationNotFound(id);
217
+ if (result.changed) {
218
+ await runtime.auditSink.recordCriticalEvent(
219
+ notificationEvent(context, "notification.update", id),
220
+ );
221
+ }
222
+ return result;
223
+ },
224
+ ),
82
225
  );
83
226
  app.delete("/system/notifications/:id", (raw) =>
84
- guarded(raw, "DELETE", "manage", feature, runtime, async (context) => {
85
- const id = context.params.id || "";
86
- const notification = await repository.archive(
87
- id,
88
- context.guardContext.actor,
89
- );
90
- if (!notification) return notFound(id);
91
- await runtime.auditSink.recordCriticalEvent(
92
- event(context, "notification.archive", id),
93
- );
94
- return notification;
95
- }),
227
+ guarded(
228
+ raw,
229
+ "DELETE",
230
+ "/api/system/notifications/:id",
231
+ "manage",
232
+ feature,
233
+ runtime,
234
+ async (context) => {
235
+ const id = context.params.id || "";
236
+ const notification = await repository.archive(
237
+ id,
238
+ context.guardContext.actor,
239
+ );
240
+ if (!notification) return notificationNotFound(id);
241
+ await runtime.auditSink.recordCriticalEvent(
242
+ notificationEvent(context, "notification.archive", id),
243
+ );
244
+ return notification;
245
+ },
246
+ ),
96
247
  );
248
+ if (service) {
249
+ registerNotificationExtensionRoutes(app, service, runtime, feature);
250
+ }
97
251
  return app;
98
252
  }
99
253
 
100
254
  async function guarded(
101
255
  raw: unknown,
102
256
  method: HttpMethod,
103
- action: "read" | "manage",
257
+ path: string,
258
+ action: "read" | "update" | "manage",
104
259
  feature: FeatureFlagDefinition | undefined,
105
260
  runtime: ServerRuntime,
106
- handler: (context: RouteContext) => unknown,
261
+ handler: (context: NotificationRouteContext) => unknown,
107
262
  ) {
108
- const context = raw as RouteContext;
263
+ const context = raw as NotificationRouteContext;
109
264
  const decision = await evaluateRouteGuards(runtime, context.guardContext, {
110
265
  permission: {
111
266
  method,
112
- path: "/api/notifications",
267
+ path,
113
268
  permissionKey: `system.notification.${action}`,
114
269
  },
115
270
  feature,
@@ -121,65 +276,3 @@ async function guarded(
121
276
  { status: 403 },
122
277
  );
123
278
  }
124
-
125
- function parsePublishInput(body: unknown): PublishNotificationInput | Response {
126
- const value =
127
- body && typeof body === "object" ? (body as Record<string, unknown>) : {};
128
- const input = {
129
- title: text(value.title),
130
- summary: text(value.summary),
131
- content: text(value.content),
132
- category: text(value.category),
133
- };
134
- if (!input.title || !input.summary || !input.content || !input.category) {
135
- return Response.json(
136
- {
137
- error: "INVALID_NOTIFICATION",
138
- message: "标题、摘要、正文和分类均不能为空",
139
- },
140
- { status: 400 },
141
- );
142
- }
143
- if (
144
- input.title.length > 120 ||
145
- input.summary.length > 240 ||
146
- input.content.length > 10_000 ||
147
- input.category.length > 40
148
- ) {
149
- return Response.json(
150
- { error: "INVALID_NOTIFICATION", message: "通知内容超过长度限制" },
151
- { status: 400 },
152
- );
153
- }
154
- return input;
155
- }
156
-
157
- function event(context: RouteContext, action: AuditActionType, id: string) {
158
- return {
159
- id: randomUUIDv7(),
160
- action,
161
- outcome: "success" as const,
162
- actor: {
163
- id: context.guardContext.actor.id,
164
- type:
165
- context.guardContext.actor.type === "anonymous"
166
- ? ("user" as const)
167
- : context.guardContext.actor.type,
168
- name: context.guardContext.actor.name,
169
- },
170
- target: { type: "notification", id },
171
- requestId: context.guardContext.requestId,
172
- occurredAt: new Date().toISOString(),
173
- };
174
- }
175
-
176
- function notFound(id: string) {
177
- return Response.json(
178
- { error: "NOT_FOUND", resource: "notification", id },
179
- { status: 404 },
180
- );
181
- }
182
-
183
- function text(value: unknown) {
184
- return typeof value === "string" ? value.trim() : "";
185
- }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { NotificationRecipientDirectory } from "./recipient-directory.js";
3
+ import { InMemoryNotificationDeliveryRepository } from "./memory-delivery-repository.js";
4
+ import { InMemoryNotificationPreferenceRepository } from "./preference-repository.js";
5
+ import { InMemoryNotificationRepository } from "./repository.js";
6
+ import { NotificationService } from "./service.js";
7
+
8
+ const recipients: NotificationRecipientDirectory = {
9
+ async options() {
10
+ return {
11
+ users: [{ id: "u1", label: "用户一" }],
12
+ roles: [],
13
+ departments: [],
14
+ };
15
+ },
16
+ async resolve() {
17
+ return [{ id: "u1", email: "u1@example.com" }];
18
+ },
19
+ async address() {
20
+ return "u1@example.com";
21
+ },
22
+ };
23
+
24
+ describe("NotificationService", () => {
25
+ test("普通通知只为用户启用且发布者请求的渠道创建 outbox", async () => {
26
+ const fixture = createFixture();
27
+ await fixture.preferences.save(
28
+ "u1",
29
+ { enabledChannels: ["email"], disabledCategories: [] },
30
+ "u1",
31
+ );
32
+ const notification = await fixture.service.publish(
33
+ {
34
+ title: "运营通知",
35
+ summary: "摘要",
36
+ content: "正文",
37
+ category: "运营",
38
+ deliveryChannels: ["email", "sms"],
39
+ },
40
+ actor,
41
+ );
42
+ expect(notification.status).toBe("published");
43
+ expect(await fixture.deliveries.list(notification.id)).toMatchObject([
44
+ { userId: "u1", channel: "email", status: "pending" },
45
+ ]);
46
+ });
47
+
48
+ test("定时通知到期前不入 outbox,到期后幂等激活并入队", async () => {
49
+ const fixture = createFixture();
50
+ await fixture.preferences.save(
51
+ "u1",
52
+ { enabledChannels: ["email"], disabledCategories: [] },
53
+ "u1",
54
+ );
55
+ const scheduledFor = "2030-01-01T00:00:00.000Z";
56
+ const notification = await fixture.service.publish(
57
+ {
58
+ title: "定时通知",
59
+ summary: "摘要",
60
+ content: "正文",
61
+ category: "运营",
62
+ deliveryChannels: ["email"],
63
+ scheduledFor,
64
+ },
65
+ actor,
66
+ );
67
+ expect(notification.status).toBe("scheduled");
68
+ expect(await fixture.deliveries.list(notification.id)).toEqual([]);
69
+ expect(
70
+ await fixture.service.dispatchScheduled(
71
+ new Date("2030-01-01T00:00:01.000Z"),
72
+ ),
73
+ ).toEqual({ claimed: 1, published: 1, deliveries: 1 });
74
+ expect(
75
+ await fixture.service.dispatchScheduled(
76
+ new Date("2030-01-01T00:00:02.000Z"),
77
+ ),
78
+ ).toEqual({ claimed: 0, published: 0, deliveries: 0 });
79
+ });
80
+ });
81
+
82
+ function createFixture() {
83
+ const preferences = new InMemoryNotificationPreferenceRepository();
84
+ const deliveries = new InMemoryNotificationDeliveryRepository();
85
+ return {
86
+ preferences,
87
+ deliveries,
88
+ service: new NotificationService(
89
+ new InMemoryNotificationRepository(),
90
+ preferences,
91
+ deliveries,
92
+ recipients,
93
+ { mandatoryCategories: ["安全"] },
94
+ ),
95
+ };
96
+ }
97
+
98
+ const actor = {
99
+ id: "admin",
100
+ type: "user" as const,
101
+ name: "管理员",
102
+ roleCodes: ["admin"],
103
+ permissionKeys: ["system.notification.manage"],
104
+ };
@@ -0,0 +1,140 @@
1
+ import type {
2
+ ExternalNotificationChannel,
3
+ PlatformNotification,
4
+ PublishNotificationInput,
5
+ UpdateNotificationPreferenceInput,
6
+ } from "@southwind-ai/shared";
7
+ import type { RequestActor } from "../guards.js";
8
+ import type { NotificationDeliveryRepository } from "./delivery-repository.js";
9
+ import { mergeExternalDeliveryPolicy } from "./delivery-policy.js";
10
+ import type { NotificationPreferenceRepository } from "./preference-repository.js";
11
+ import type { NotificationRecipientDirectory } from "./recipient-directory.js";
12
+ import type { NotificationRepository } from "./repository.js";
13
+
14
+ export interface NotificationServiceOptions {
15
+ mandatoryCategories?: string[];
16
+ }
17
+
18
+ export class NotificationService {
19
+ private readonly mandatoryCategories: ReadonlySet<string>;
20
+
21
+ constructor(
22
+ readonly repository: NotificationRepository,
23
+ private readonly preferences: NotificationPreferenceRepository,
24
+ private readonly deliveries: NotificationDeliveryRepository,
25
+ private readonly recipients: NotificationRecipientDirectory,
26
+ options: NotificationServiceOptions = {},
27
+ ) {
28
+ this.mandatoryCategories = new Set(options.mandatoryCategories || []);
29
+ }
30
+
31
+ async publish(input: PublishNotificationInput, actor: RequestActor) {
32
+ const normalized = normalizePublishInput(input);
33
+ const notification = await this.repository.publish(normalized, actor);
34
+ if (notification.status === "published") {
35
+ await this.enqueue(notification);
36
+ }
37
+ return notification;
38
+ }
39
+
40
+ async dispatchScheduled(now = new Date()) {
41
+ const due = await this.repository.listDueScheduled(now, 20);
42
+ let published = 0;
43
+ let deliveries = 0;
44
+ for (const notification of due) {
45
+ const activated = await this.repository.activateScheduled(
46
+ notification.id,
47
+ now,
48
+ );
49
+ if (!activated) continue;
50
+ published += 1;
51
+ deliveries += await this.enqueue(activated);
52
+ }
53
+ return { claimed: due.length, published, deliveries };
54
+ }
55
+
56
+ getPreference(userId: string) {
57
+ return this.preferences.get(userId);
58
+ }
59
+
60
+ savePreference(
61
+ userId: string,
62
+ input: UpdateNotificationPreferenceInput,
63
+ actorId: string,
64
+ ) {
65
+ return this.preferences.save(userId, normalizePreference(input), actorId);
66
+ }
67
+
68
+ audienceOptions() {
69
+ return this.recipients.options();
70
+ }
71
+
72
+ listDeliveries(notificationId: string) {
73
+ return this.deliveries.list(notificationId);
74
+ }
75
+
76
+ private async enqueue(notification: PlatformNotification) {
77
+ const recipients = await this.recipients.resolve(notification);
78
+ const drafts = [];
79
+ for (const recipient of recipients) {
80
+ const preference = await this.preferences.get(recipient.id);
81
+ const channels = mergeExternalDeliveryPolicy({
82
+ category: notification.category,
83
+ requestedChannels: notification.deliveryChannels || [],
84
+ preference,
85
+ mandatoryCategories: this.mandatoryCategories,
86
+ });
87
+ for (const channel of channels) {
88
+ if (!hasAddress(recipient, channel)) continue;
89
+ drafts.push({
90
+ notificationId: notification.id,
91
+ userId: recipient.id,
92
+ channel,
93
+ subject: notification.title,
94
+ content: notification.content,
95
+ });
96
+ }
97
+ }
98
+ return this.deliveries.enqueue(drafts);
99
+ }
100
+ }
101
+
102
+ function normalizePublishInput(
103
+ input: PublishNotificationInput,
104
+ ): PublishNotificationInput {
105
+ return {
106
+ ...input,
107
+ recipientUserIds: unique([
108
+ ...(input.recipientUserIds || []),
109
+ ...(input.recipientUserId ? [input.recipientUserId] : []),
110
+ ]),
111
+ recipientRoleCodes: unique(input.recipientRoleCodes || []),
112
+ recipientDepartmentIds: unique(input.recipientDepartmentIds || []),
113
+ deliveryChannels: unique(input.deliveryChannels || []),
114
+ includeChildDepartments: Boolean(input.includeChildDepartments),
115
+ };
116
+ }
117
+
118
+ function normalizePreference(input: UpdateNotificationPreferenceInput) {
119
+ return {
120
+ enabledChannels: unique(input.enabledChannels).filter(isChannel),
121
+ disabledCategories: unique(
122
+ input.disabledCategories.map((item) => item.trim()).filter(Boolean),
123
+ ),
124
+ };
125
+ }
126
+
127
+ function hasAddress(
128
+ recipient: { email?: string; phone?: string },
129
+ channel: ExternalNotificationChannel,
130
+ ) {
131
+ return Boolean(channel === "email" ? recipient.email : recipient.phone);
132
+ }
133
+
134
+ function isChannel(value: string): value is ExternalNotificationChannel {
135
+ return value === "email" || value === "sms";
136
+ }
137
+
138
+ function unique<T>(values: T[]) {
139
+ return [...new Set(values)];
140
+ }