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
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PublishNotificationInput } from "@southwind-ai/shared";
3
+ import type { RequestActor } from "../guards.js";
4
+ import { InMemoryNotificationRepository } from "./repository.js";
5
+
6
+ describe("内存通知收件箱", () => {
7
+ test("关键词、分类、已读和收藏组合在分页前筛选并返回正确总数", async () => {
8
+ const repository = new InMemoryNotificationRepository();
9
+ await repository.publish(notice("季度安全提醒", "安全"), admin);
10
+ const first = await repository.publish(
11
+ notice("安全维护窗口", "安全"),
12
+ admin,
13
+ );
14
+ await repository.publish(notice("产品更新", "产品"), admin);
15
+ await repository.markRead(first.id, userA);
16
+ await repository.setFavorite(first.id, true, userA);
17
+
18
+ const result = await repository.listInbox(userA, 1, 1, {
19
+ q: "维护",
20
+ category: "安全",
21
+ read: "read",
22
+ favorite: true,
23
+ });
24
+
25
+ expect(result).toMatchObject({ total: 1, page: 1, pageSize: 1 });
26
+ expect(result.items[0]).toMatchObject({
27
+ id: first.id,
28
+ read: true,
29
+ favorite: true,
30
+ });
31
+ });
32
+
33
+ test("收藏按 Actor 隔离,且与已读状态相互独立", async () => {
34
+ const repository = new InMemoryNotificationRepository();
35
+ const published = await repository.publish(
36
+ notice("独立状态测试", "系统"),
37
+ admin,
38
+ );
39
+ await repository.setFavorite(published.id, true, userA);
40
+
41
+ expect((await repository.listInbox(userA, 1, 10)).items[0]).toMatchObject({
42
+ favorite: true,
43
+ read: false,
44
+ });
45
+ expect((await repository.listInbox(userB, 1, 10)).items[0]).toMatchObject({
46
+ favorite: false,
47
+ read: false,
48
+ });
49
+
50
+ await repository.markRead(published.id, userA);
51
+ await repository.setFavorite(published.id, false, userA);
52
+ expect((await repository.listInbox(userA, 1, 10)).items[0]).toMatchObject({
53
+ favorite: false,
54
+ read: true,
55
+ });
56
+ });
57
+
58
+ test("不可见通知不能通过猜测 ID 收藏或取消收藏", async () => {
59
+ const repository = new InMemoryNotificationRepository();
60
+ const targeted = await repository.publish(
61
+ {
62
+ ...notice("定向通知", "审批"),
63
+ recipientUserId: userA.id,
64
+ },
65
+ admin,
66
+ );
67
+
68
+ expect(
69
+ await repository.setFavorite(targeted.id, true, userB),
70
+ ).toBeUndefined();
71
+ expect(
72
+ await repository.setFavorite(targeted.id, false, userB),
73
+ ).toBeUndefined();
74
+ expect((await repository.listInbox(userB, 1, 10)).total).toBe(0);
75
+ });
76
+
77
+ test("快速重复收藏和取消收藏保持幂等", async () => {
78
+ const repository = new InMemoryNotificationRepository();
79
+ const published = await repository.publish(
80
+ notice("重复操作测试", "系统"),
81
+ admin,
82
+ );
83
+
84
+ const favorites = await Promise.all([
85
+ repository.setFavorite(published.id, true, userA),
86
+ repository.setFavorite(published.id, true, userA),
87
+ ]);
88
+ expect(favorites.filter((result) => result?.changed)).toHaveLength(1);
89
+ expect(
90
+ (await repository.listInbox(userA, 1, 10, { favorite: true })).total,
91
+ ).toBe(1);
92
+
93
+ const removals = await Promise.all([
94
+ repository.setFavorite(published.id, false, userA),
95
+ repository.setFavorite(published.id, false, userA),
96
+ ]);
97
+ expect(removals.filter((result) => result?.changed)).toHaveLength(1);
98
+ expect(
99
+ (await repository.listInbox(userA, 1, 10, { favorite: true })).total,
100
+ ).toBe(0);
101
+ });
102
+
103
+ test("发布形成初始版本,修改追加不可变历史和时间", async () => {
104
+ const repository = new InMemoryNotificationRepository();
105
+ const published = await repository.publish(
106
+ notice("初始公告", "系统"),
107
+ admin,
108
+ );
109
+ const updated = await repository.update(
110
+ published.id,
111
+ notice("修改公告", "安全"),
112
+ userA,
113
+ );
114
+ const repeated = await repository.update(
115
+ published.id,
116
+ notice("修改公告", "安全"),
117
+ userA,
118
+ );
119
+ const history = await repository.listRevisions(published.id, 1, 10);
120
+
121
+ expect(updated).toMatchObject({
122
+ changed: true,
123
+ notification: { revision: 2, title: "修改公告" },
124
+ });
125
+ expect(repeated).toMatchObject({ changed: false });
126
+ expect(history?.total).toBe(2);
127
+ expect(history?.items).toMatchObject([
128
+ { revision: 2, editedBy: userA.id, editedAt: expect.any(String) },
129
+ { revision: 1, editedBy: admin.id, editedAt: expect.any(String) },
130
+ ]);
131
+ });
132
+
133
+ test("归档公告保持历史可读但拒绝继续修改", async () => {
134
+ const repository = new InMemoryNotificationRepository();
135
+ const published = await repository.publish(
136
+ notice("待归档公告", "系统"),
137
+ admin,
138
+ );
139
+ await repository.archive(published.id, admin);
140
+
141
+ expect(
142
+ await repository.update(published.id, notice("越界修改", "系统"), admin),
143
+ ).toBeUndefined();
144
+ expect((await repository.listRevisions(published.id, 1, 10))?.total).toBe(
145
+ 1,
146
+ );
147
+ });
148
+ });
149
+
150
+ function notice(title: string, category: string): PublishNotificationInput {
151
+ return {
152
+ title,
153
+ summary: `${title}摘要`,
154
+ content: `${title}正文`,
155
+ category,
156
+ };
157
+ }
158
+
159
+ const admin = actor("admin", ["admin"]);
160
+ const userA = actor("user-a", ["member"]);
161
+ const userB = actor("user-b", ["member"]);
162
+
163
+ function actor(id: string, roleCodes: string[]): RequestActor {
164
+ return {
165
+ id,
166
+ name: id,
167
+ type: "user",
168
+ roleCodes,
169
+ permissionKeys: ["system.notification.read"],
170
+ };
171
+ }
@@ -1,18 +1,30 @@
1
1
  import { randomUUIDv7 } from "bun";
2
2
  import type {
3
3
  MarkNotificationReadResult,
4
+ NotificationInboxFilters,
4
5
  NotificationInboxItem,
6
+ NotificationRevision,
5
7
  PageResult,
6
8
  PlatformNotification,
7
9
  PublishNotificationInput,
10
+ SetNotificationFavoriteResult,
11
+ UpdateNotificationInput,
12
+ UpdateNotificationResult,
8
13
  } from "@southwind-ai/shared";
9
14
  import type { RequestActor } from "../guards.js";
15
+ import {
16
+ pageResult,
17
+ sameContent,
18
+ toRevision,
19
+ visibleTo,
20
+ } from "./memory-support.js";
10
21
 
11
22
  export interface NotificationRepository {
12
23
  listInbox(
13
- actor: Pick<RequestActor, "id" | "roleCodes">,
24
+ actor: Pick<RequestActor, "id" | "roleCodes" | "scopeContext">,
14
25
  page: number,
15
26
  pageSize: number,
27
+ filters?: NotificationInboxFilters,
16
28
  ): Promise<PageResult<NotificationInboxItem>>;
17
29
  listManaged(
18
30
  page: number,
@@ -23,10 +35,30 @@ export interface NotificationRepository {
23
35
  input: PublishNotificationInput,
24
36
  actor: RequestActor,
25
37
  ): Promise<PlatformNotification>;
38
+ listDueScheduled(now: Date, limit: number): Promise<PlatformNotification[]>;
39
+ activateScheduled(
40
+ notificationId: string,
41
+ now: Date,
42
+ ): Promise<PlatformNotification | undefined>;
43
+ update(
44
+ notificationId: string,
45
+ input: UpdateNotificationInput,
46
+ actor: RequestActor,
47
+ ): Promise<UpdateNotificationResult | undefined>;
48
+ listRevisions(
49
+ notificationId: string,
50
+ page: number,
51
+ pageSize: number,
52
+ ): Promise<PageResult<NotificationRevision> | undefined>;
26
53
  markRead(
27
54
  notificationId: string,
28
- actor: Pick<RequestActor, "id" | "roleCodes">,
55
+ actor: Pick<RequestActor, "id" | "roleCodes" | "scopeContext">,
29
56
  ): Promise<MarkNotificationReadResult | undefined>;
57
+ setFavorite(
58
+ notificationId: string,
59
+ favorite: boolean,
60
+ actor: Pick<RequestActor, "id" | "roleCodes" | "scopeContext">,
61
+ ): Promise<SetNotificationFavoriteResult | undefined>;
30
62
  archive(
31
63
  notificationId: string,
32
64
  actor: RequestActor,
@@ -36,21 +68,49 @@ export interface NotificationRepository {
36
68
  export class InMemoryNotificationRepository implements NotificationRepository {
37
69
  private readonly notifications = new Map<string, PlatformNotification>();
38
70
  private readonly reads = new Map<string, string>();
71
+ private readonly favorites = new Map<string, string>();
72
+ private readonly revisions = new Map<string, NotificationRevision[]>();
39
73
  private readonly sources = new Map<string, string>();
40
74
 
41
75
  async listInbox(
42
- actor: Pick<RequestActor, "id" | "roleCodes">,
76
+ actor: Pick<RequestActor, "id" | "roleCodes" | "scopeContext">,
43
77
  page: number,
44
78
  pageSize: number,
79
+ filters: NotificationInboxFilters = {},
45
80
  ) {
81
+ const keyword = filters.q?.trim().toLowerCase();
82
+ const category = filters.category?.trim();
46
83
  const items = [...this.notifications.values()]
47
84
  .filter(({ status }) => status === "published")
48
85
  .filter((item) => visibleTo(item, actor))
86
+ .filter((item) =>
87
+ keyword
88
+ ? [item.title, item.summary, item.content, item.category].some(
89
+ (value) => value.toLowerCase().includes(keyword),
90
+ )
91
+ : true,
92
+ )
93
+ .filter((item) => (category ? item.category === category : true))
49
94
  .sort((left, right) => right.publishedAt.localeCompare(left.publishedAt))
50
95
  .map((item) => {
51
96
  const readAt = this.reads.get(readKey(item.id, actor.id));
52
- return { ...item, read: Boolean(readAt), readAt };
53
- });
97
+ const favoritedAt = this.favorites.get(favoriteKey(item.id, actor.id));
98
+ return {
99
+ ...item,
100
+ read: Boolean(readAt),
101
+ readAt,
102
+ favorite: Boolean(favoritedAt),
103
+ favoritedAt,
104
+ };
105
+ })
106
+ .filter((item) =>
107
+ filters.read ? item.read === (filters.read === "read") : true,
108
+ )
109
+ .filter((item) =>
110
+ filters.favorite === undefined
111
+ ? true
112
+ : item.favorite === filters.favorite,
113
+ );
54
114
  return pageResult(items, page, pageSize);
55
115
  }
56
116
 
@@ -80,21 +140,92 @@ export class InMemoryNotificationRepository implements NotificationRepository {
80
140
  const notification: PlatformNotification = {
81
141
  id: randomUUIDv7(),
82
142
  ...input,
83
- status: "published",
84
- publishedAt: now,
143
+ status: input.scheduledFor ? "scheduled" : "published",
144
+ publishedAt: input.scheduledFor || now,
85
145
  createdAt: now,
86
146
  createdBy: actor.id,
87
147
  updatedAt: now,
88
148
  updatedBy: actor.id,
149
+ revision: 1,
89
150
  };
90
151
  this.notifications.set(notification.id, notification);
152
+ this.revisions.set(notification.id, [
153
+ toRevision(notification, now, actor.id),
154
+ ]);
91
155
  if (input.sourceKey) this.sources.set(input.sourceKey, notification.id);
92
156
  return notification;
93
157
  }
94
158
 
159
+ async listDueScheduled(now: Date, limit: number) {
160
+ return [...this.notifications.values()]
161
+ .filter(
162
+ (item) =>
163
+ item.status === "scheduled" &&
164
+ Boolean(item.scheduledFor && item.scheduledFor <= now.toISOString()),
165
+ )
166
+ .sort((left, right) =>
167
+ (left.scheduledFor || "").localeCompare(right.scheduledFor || ""),
168
+ )
169
+ .slice(0, limit);
170
+ }
171
+
172
+ async activateScheduled(notificationId: string, now: Date) {
173
+ const current = this.notifications.get(notificationId);
174
+ if (
175
+ !current ||
176
+ current.status !== "scheduled" ||
177
+ !current.scheduledFor ||
178
+ current.scheduledFor > now.toISOString()
179
+ ) {
180
+ return undefined;
181
+ }
182
+ const activated: PlatformNotification = {
183
+ ...current,
184
+ status: "published",
185
+ publishedAt: now.toISOString(),
186
+ updatedAt: now.toISOString(),
187
+ updatedBy: "notification-scheduler",
188
+ };
189
+ this.notifications.set(notificationId, activated);
190
+ return activated;
191
+ }
192
+
193
+ async update(
194
+ notificationId: string,
195
+ input: UpdateNotificationInput,
196
+ actor: RequestActor,
197
+ ) {
198
+ const current = this.notifications.get(notificationId);
199
+ if (!current || current.status !== "published") return undefined;
200
+ if (sameContent(current, input)) {
201
+ return { notification: current, changed: false };
202
+ }
203
+ const editedAt = new Date().toISOString();
204
+ const notification: PlatformNotification = {
205
+ ...current,
206
+ ...input,
207
+ revision: current.revision + 1,
208
+ updatedAt: editedAt,
209
+ updatedBy: actor.id,
210
+ };
211
+ this.notifications.set(notificationId, notification);
212
+ const history = this.revisions.get(notificationId) || [];
213
+ history.push(toRevision(notification, editedAt, actor.id));
214
+ this.revisions.set(notificationId, history);
215
+ return { notification, changed: true };
216
+ }
217
+
218
+ async listRevisions(notificationId: string, page: number, pageSize: number) {
219
+ if (!this.notifications.has(notificationId)) return undefined;
220
+ const history = [...(this.revisions.get(notificationId) || [])].sort(
221
+ (left, right) => right.revision - left.revision,
222
+ );
223
+ return pageResult(history, page, pageSize);
224
+ }
225
+
95
226
  async markRead(
96
227
  notificationId: string,
97
- actor: Pick<RequestActor, "id" | "roleCodes">,
228
+ actor: Pick<RequestActor, "id" | "roleCodes" | "scopeContext">,
98
229
  ) {
99
230
  const notification = this.notifications.get(notificationId);
100
231
  if (
@@ -112,6 +243,44 @@ export class InMemoryNotificationRepository implements NotificationRepository {
112
243
  return { success: true, alreadyRead: false, readAt } as const;
113
244
  }
114
245
 
246
+ async setFavorite(
247
+ notificationId: string,
248
+ favorite: boolean,
249
+ actor: Pick<RequestActor, "id" | "roleCodes" | "scopeContext">,
250
+ ) {
251
+ const notification = this.notifications.get(notificationId);
252
+ if (
253
+ !notification ||
254
+ notification.status !== "published" ||
255
+ !visibleTo(notification, actor)
256
+ )
257
+ return undefined;
258
+ const key = favoriteKey(notificationId, actor.id);
259
+ const existing = this.favorites.get(key);
260
+ if (favorite) {
261
+ if (existing) {
262
+ return {
263
+ success: true,
264
+ favorite: true,
265
+ changed: false,
266
+ favoritedAt: existing,
267
+ } as const;
268
+ }
269
+ const favoritedAt = new Date().toISOString();
270
+ this.favorites.set(key, favoritedAt);
271
+ return {
272
+ success: true,
273
+ favorite: true,
274
+ changed: true,
275
+ favoritedAt,
276
+ } as const;
277
+ }
278
+ if (!existing)
279
+ return { success: true, favorite: false, changed: false } as const;
280
+ this.favorites.delete(key);
281
+ return { success: true, favorite: false, changed: true } as const;
282
+ }
283
+
115
284
  async archive(notificationId: string, actor: RequestActor) {
116
285
  const current = this.notifications.get(notificationId);
117
286
  if (!current || current.status === "archived") return undefined;
@@ -129,32 +298,10 @@ export class InMemoryNotificationRepository implements NotificationRepository {
129
298
  }
130
299
  }
131
300
 
132
- function visibleTo(
133
- item: PlatformNotification,
134
- actor: Pick<RequestActor, "id" | "roleCodes">,
135
- ) {
136
- const roles = item.recipientRoleCodes || [];
137
- return (
138
- (!item.recipientUserId && !roles.length) ||
139
- item.recipientUserId === actor.id ||
140
- roles.some((role) => actor.roleCodes.includes(role))
141
- );
142
- }
143
-
144
301
  function readKey(notificationId: string, userId: string) {
145
302
  return `${notificationId}:${userId}`;
146
303
  }
147
304
 
148
- function pageResult<T>(
149
- items: T[],
150
- page: number,
151
- pageSize: number,
152
- ): PageResult<T> {
153
- const start = (page - 1) * pageSize;
154
- return {
155
- items: items.slice(start, start + pageSize),
156
- total: items.length,
157
- page,
158
- pageSize,
159
- };
305
+ function favoriteKey(notificationId: string, userId: string) {
306
+ return `${notificationId}:${userId}`;
160
307
  }
@@ -0,0 +1,186 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createFoundationSnapshot } from "../foundation.js";
3
+ import { createServerRuntime } from "../runtime.js";
4
+ import { InMemoryNotificationRepository } from "./repository.js";
5
+ import { registerNotificationRoutes } from "./routes.js";
6
+
7
+ class FakeRouteApp {
8
+ readonly handlers = new Map<string, (context: unknown) => unknown>();
9
+
10
+ get(path: string, handler: (context: unknown) => unknown) {
11
+ this.handlers.set(`GET ${path}`, handler);
12
+ }
13
+
14
+ post(path: string, handler: (context: unknown) => unknown) {
15
+ this.handlers.set(`POST ${path}`, handler);
16
+ }
17
+
18
+ put(path: string, handler: (context: unknown) => unknown) {
19
+ this.handlers.set(`PUT ${path}`, handler);
20
+ }
21
+
22
+ delete(path: string, handler: (context: unknown) => unknown) {
23
+ this.handlers.set(`DELETE ${path}`, handler);
24
+ }
25
+
26
+ invoke(method: string, path: string, context: unknown) {
27
+ const handler = this.handlers.get(`${method} ${path}`);
28
+ if (!handler) throw new Error(`未注册路由 ${method} ${path}`);
29
+ return handler(context);
30
+ }
31
+ }
32
+
33
+ describe("公告修改与历史 API", () => {
34
+ test("管理员修改公告并分页查看版本、修改人和时间", async () => {
35
+ const runtime = createServerRuntime({ WINDY_DEV_ADMIN_TOKEN: "token_1" });
36
+ const app = new FakeRouteApp();
37
+ registerNotificationRoutes(
38
+ app,
39
+ new InMemoryNotificationRepository(),
40
+ runtime,
41
+ createFoundationSnapshot().features,
42
+ );
43
+ const guardContext = await runtime.createGuardContext(
44
+ new Request("http://localhost/api/system/notifications", {
45
+ headers: { authorization: "Bearer token_1" },
46
+ }),
47
+ );
48
+ const published = (await app.invoke("POST", "/system/notifications", {
49
+ guardContext,
50
+ params: {},
51
+ query: {},
52
+ body: notice("初始公告"),
53
+ })) as { id: string; revision: number };
54
+
55
+ const updated = await app.invoke("PUT", "/system/notifications/:id", {
56
+ guardContext,
57
+ params: { id: published.id },
58
+ query: {},
59
+ body: notice("修改公告"),
60
+ });
61
+ const repeated = await app.invoke("PUT", "/system/notifications/:id", {
62
+ guardContext,
63
+ params: { id: published.id },
64
+ query: {},
65
+ body: notice("修改公告"),
66
+ });
67
+ const history = (await app.invoke(
68
+ "GET",
69
+ "/system/notifications/:id/revisions",
70
+ {
71
+ guardContext,
72
+ params: { id: published.id },
73
+ query: { page: "1", pageSize: "1" },
74
+ },
75
+ )) as {
76
+ total: number;
77
+ items: Array<{ revision: number; editedAt: string; editedBy: string }>;
78
+ };
79
+
80
+ expect(published.revision).toBe(1);
81
+ expect(updated).toMatchObject({
82
+ changed: true,
83
+ notification: { revision: 2, title: "修改公告" },
84
+ });
85
+ expect(repeated).toMatchObject({ changed: false });
86
+ expect(history.total).toBe(2);
87
+ expect(history.items[0]).toMatchObject({
88
+ revision: 2,
89
+ editedAt: expect.any(String),
90
+ editedBy: expect.any(String),
91
+ });
92
+ expect(runtime.auditSink.list().map(({ action }) => action)).toEqual([
93
+ "notification.publish",
94
+ "notification.update",
95
+ ]);
96
+ });
97
+
98
+ test("修改与历史分别要求 update 和 manage 权限,非法内容被拒绝", async () => {
99
+ const runtime = createServerRuntime();
100
+ const repository = new InMemoryNotificationRepository();
101
+ const app = new FakeRouteApp();
102
+ registerNotificationRoutes(
103
+ app,
104
+ repository,
105
+ runtime,
106
+ createFoundationSnapshot().features,
107
+ );
108
+ const actor = {
109
+ id: "reader",
110
+ type: "user" as const,
111
+ name: "普通用户",
112
+ roleCodes: ["member"],
113
+ permissionKeys: ["system.notification.read"],
114
+ };
115
+ const guardContext = await runtime.createGuardContextForActor(
116
+ new Request("http://localhost/api/system/notifications"),
117
+ actor,
118
+ );
119
+ const published = await repository.publish(notice("受保护公告"), {
120
+ ...actor,
121
+ id: "publisher",
122
+ });
123
+
124
+ for (const [method, path] of [
125
+ ["PUT", "/system/notifications/:id"],
126
+ ["GET", "/system/notifications/:id/revisions"],
127
+ ]) {
128
+ const response = (await app.invoke(method!, path!, {
129
+ guardContext,
130
+ params: { id: published.id },
131
+ query: {},
132
+ body: notice("越权修改"),
133
+ })) as Response;
134
+ expect(response.status).toBe(403);
135
+ }
136
+
137
+ const manageContext = {
138
+ ...guardContext,
139
+ actor: {
140
+ ...actor,
141
+ permissionKeys: ["system.notification.manage"],
142
+ },
143
+ };
144
+ const managedHistory = await app.invoke(
145
+ "GET",
146
+ "/system/notifications/:id/revisions",
147
+ {
148
+ guardContext: manageContext,
149
+ params: { id: published.id },
150
+ query: {},
151
+ },
152
+ );
153
+ expect(managedHistory).toMatchObject({ total: 1 });
154
+ const manageUpdate = (await app.invoke("PUT", "/system/notifications/:id", {
155
+ guardContext: manageContext,
156
+ params: { id: published.id },
157
+ query: {},
158
+ body: notice("无修改权限"),
159
+ })) as Response;
160
+ expect(manageUpdate.status).toBe(403);
161
+
162
+ const updateContext = {
163
+ ...guardContext,
164
+ actor: {
165
+ ...actor,
166
+ permissionKeys: ["system.notification.update"],
167
+ },
168
+ };
169
+ const invalid = (await app.invoke("PUT", "/system/notifications/:id", {
170
+ guardContext: updateContext,
171
+ params: { id: published.id },
172
+ query: {},
173
+ body: { ...notice(""), title: "" },
174
+ })) as Response;
175
+ expect(invalid.status).toBe(400);
176
+ });
177
+ });
178
+
179
+ function notice(title: string) {
180
+ return {
181
+ title,
182
+ summary: `${title}摘要`,
183
+ content: `${title}正文`,
184
+ category: "系统",
185
+ };
186
+ }