create-windy 0.2.34 → 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 (237) hide show
  1. package/dist/cli.js +167 -17
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/AGENTS.md +5 -0
  5. package/template/README.md +3 -1
  6. package/template/apps/agent-server/Dockerfile +1 -0
  7. package/template/apps/agent-server/README.md +18 -5
  8. package/template/apps/agent-server/adk_web_agents/__init__.py +1 -0
  9. package/template/apps/agent-server/adk_web_agents/safe_debug/__init__.py +3 -0
  10. package/template/apps/agent-server/adk_web_agents/safe_debug/agent.py +40 -0
  11. package/template/apps/agent-server/pyproject.toml +1 -1
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +191 -16
  13. package/template/apps/agent-server/src/southwind_agent_server/adk_failure.py +13 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +225 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +76 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/app.py +1 -2
  17. package/template/apps/agent-server/tests/test_adk_adapter.py +305 -0
  18. package/template/apps/agent-server/tests/test_adk_web_agent.py +12 -0
  19. package/template/apps/server/package.json +4 -1
  20. package/template/apps/server/src/agent/run-facade.ts +2 -7
  21. package/template/apps/server/src/agent/tool-registry.ts +2 -6
  22. package/template/apps/server/src/application-services.ts +50 -0
  23. package/template/apps/server/src/audit/drizzle-log-store.ts +25 -0
  24. package/template/apps/server/src/audit/search-summary.ts +2 -6
  25. package/template/apps/server/src/auth/credential-restriction.ts +1 -0
  26. package/template/apps/server/src/auth/password.ts +30 -21
  27. package/template/apps/server/src/auth/routes.test.ts +44 -3
  28. package/template/apps/server/src/auth/routes.ts +6 -1
  29. package/template/apps/server/src/auth/runtime.ts +4 -1
  30. package/template/apps/server/src/auth/service.ts +25 -7
  31. package/template/apps/server/src/configuration/bootstrap.ts +20 -1
  32. package/template/apps/server/src/configuration/definition.test.ts +71 -0
  33. package/template/apps/server/src/configuration/definition.ts +242 -12
  34. package/template/apps/server/src/configuration/drizzle-repository.ts +36 -2
  35. package/template/apps/server/src/configuration/repository.ts +27 -2
  36. package/template/apps/server/src/configuration/routes.ts +4 -1
  37. package/template/apps/server/src/configuration/service.test.ts +100 -0
  38. package/template/apps/server/src/configuration/service.ts +50 -10
  39. package/template/apps/server/src/data-access/scoped-repository.integration.test.ts +3 -2
  40. package/template/apps/server/src/data-governance/export/service.ts +2 -6
  41. package/template/apps/server/src/example-modules.ts +4 -1
  42. package/template/apps/server/src/guards.ts +18 -5
  43. package/template/apps/server/src/http/request-source.test.ts +85 -0
  44. package/template/apps/server/src/http/request-source.ts +90 -0
  45. package/template/apps/server/src/index.ts +23 -6
  46. package/template/apps/server/src/license/routes.ts +2 -0
  47. package/template/apps/server/src/license/runtime-service.ts +10 -11
  48. package/template/apps/server/src/module-bootstrap.ts +2 -2
  49. package/template/apps/server/src/module-host/request-context.ts +6 -6
  50. package/template/apps/server/src/module-host/storage-scope.ts +2 -9
  51. package/template/apps/server/src/module-host/user-directory-port.ts +2 -6
  52. package/template/apps/server/src/notification/channel-adapter.ts +46 -0
  53. package/template/apps/server/src/notification/delivery-policy.test.ts +59 -0
  54. package/template/apps/server/src/notification/delivery-policy.ts +37 -0
  55. package/template/apps/server/src/notification/delivery-repository.ts +198 -0
  56. package/template/apps/server/src/notification/delivery-worker.test.ts +87 -0
  57. package/template/apps/server/src/notification/delivery-worker.ts +88 -0
  58. package/template/apps/server/src/notification/drizzle-management.ts +180 -0
  59. package/template/apps/server/src/notification/drizzle-query.ts +160 -0
  60. package/template/apps/server/src/notification/drizzle-repository.integration.test.ts +301 -0
  61. package/template/apps/server/src/notification/drizzle-repository.ts +156 -90
  62. package/template/apps/server/src/notification/extension-routes.test.ts +119 -0
  63. package/template/apps/server/src/notification/extension-routes.ts +111 -0
  64. package/template/apps/server/src/notification/memory-delivery-repository.ts +137 -0
  65. package/template/apps/server/src/notification/memory-support.ts +72 -0
  66. package/template/apps/server/src/notification/preference-repository.ts +95 -0
  67. package/template/apps/server/src/notification/recipient-directory.ts +104 -0
  68. package/template/apps/server/src/notification/repository.test.ts +171 -0
  69. package/template/apps/server/src/notification/repository.ts +179 -32
  70. package/template/apps/server/src/notification/revision-routes.test.ts +186 -0
  71. package/template/apps/server/src/notification/route-support.ts +225 -0
  72. package/template/apps/server/src/notification/routes.test.ts +182 -0
  73. package/template/apps/server/src/notification/routes.ts +225 -132
  74. package/template/apps/server/src/notification/service.test.ts +104 -0
  75. package/template/apps/server/src/notification/service.ts +140 -0
  76. package/template/apps/server/src/notification/smtp-adapter.test.ts +65 -0
  77. package/template/apps/server/src/notification/smtp-adapter.ts +68 -0
  78. package/template/apps/server/src/persistence.integration.test.ts +41 -2
  79. package/template/apps/server/src/route-guards.ts +1 -0
  80. package/template/apps/server/src/runtime-audit-list.test.ts +39 -0
  81. package/template/apps/server/src/runtime-development.ts +1 -0
  82. package/template/apps/server/src/runtime.test.ts +23 -9
  83. package/template/apps/server/src/runtime.ts +39 -2
  84. package/template/apps/server/src/scheduler/audit.ts +2 -11
  85. package/template/apps/server/src/scheduler/definitions.test.ts +6 -0
  86. package/template/apps/server/src/scheduler/definitions.ts +7 -0
  87. package/template/apps/server/src/scheduler/recovery-service.ts +2 -11
  88. package/template/apps/server/src/scheduler/routes.ts +2 -7
  89. package/template/apps/server/src/search/opensearch-client.test.ts +45 -0
  90. package/template/apps/server/src/search/opensearch-client.ts +145 -0
  91. package/template/apps/server/src/search/service.ts +2 -6
  92. package/template/apps/server/src/settings/routes.ts +15 -3
  93. package/template/apps/server/src/settings/runtime.ts +10 -1
  94. package/template/apps/server/src/storage/runtime.ts +13 -0
  95. package/template/apps/server/src/system/audit-query.ts +33 -4
  96. package/template/apps/server/src/system/audit-routes.test.ts +58 -4
  97. package/template/apps/server/src/system/audit-routes.ts +6 -5
  98. package/template/apps/server/src/system/department-organization.ts +320 -0
  99. package/template/apps/server/src/system/department-routes.test.ts +207 -48
  100. package/template/apps/server/src/system/department-routes.ts +114 -33
  101. package/template/apps/server/src/system/drizzle-repository.ts +15 -7
  102. package/template/apps/server/src/system/drizzle-scoped-repository.ts +5 -3
  103. package/template/apps/server/src/system/drizzle-system.ts +37 -9
  104. package/template/apps/server/src/system/drizzle-transaction-context.ts +14 -0
  105. package/template/apps/server/src/system/identity-route-config.ts +7 -2
  106. package/template/apps/server/src/system/mutation-runner.ts +23 -0
  107. package/template/apps/server/src/system/organization-postgres.integration.test.ts +176 -0
  108. package/template/apps/server/src/system/resource-access-response.ts +12 -6
  109. package/template/apps/server/src/system/role-department-scope-policy.ts +55 -0
  110. package/template/apps/server/src/system/route-helpers.ts +2 -5
  111. package/template/apps/server/src/system/route-types.ts +12 -3
  112. package/template/apps/server/src/system/routes-validation.test.ts +26 -0
  113. package/template/apps/server/src/system/routes.test.ts +10 -2
  114. package/template/apps/server/src/system/routes.ts +70 -36
  115. package/template/apps/server/src/system/seed.ts +7 -0
  116. package/template/apps/server/src/work-order/data-scope.integration.test.ts +9 -2
  117. package/template/apps/server/src/work-order/opensearch-index.test.ts +97 -0
  118. package/template/apps/server/src/work-order/opensearch-index.ts +313 -0
  119. package/template/apps/server/src/work-order/search-provider.ts +60 -1
  120. package/template/apps/server/src/work-order/service.ts +42 -10
  121. package/template/apps/web/src/app/error-pages.ts +9 -0
  122. package/template/apps/web/src/components/auth/PasswordChangeForm.vue +16 -11
  123. package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +14 -0
  124. package/template/apps/web/src/components/common/FormDialog.vue +16 -2
  125. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +20 -1
  126. package/template/apps/web/src/composables/useGlobalSearch.ts +68 -0
  127. package/template/apps/web/src/composables/usePlatformSettings.ts +8 -7
  128. package/template/apps/web/src/layout/GlobalSearchResults.vue +58 -0
  129. package/template/apps/web/src/layout/NavigationSearchDialog.vue +42 -6
  130. package/template/apps/web/src/layout/NavigationSearchDialog.webtest.ts +71 -0
  131. package/template/apps/web/src/layout/NotificationMenu.vue +73 -31
  132. package/template/apps/web/src/layout/NotificationMenu.webtest.ts +44 -1
  133. package/template/apps/web/src/layout/NotificationMenuItem.vue +61 -0
  134. package/template/apps/web/src/layout/NotificationPreferencesDialog.vue +181 -0
  135. package/template/apps/web/src/layout/NotificationPreferencesDialog.webtest.ts +80 -0
  136. package/template/apps/web/src/lib/date-time.ts +22 -10
  137. package/template/apps/web/src/lib/date-time.webtest.ts +9 -1
  138. package/template/apps/web/src/main.ts +14 -1
  139. package/template/apps/web/src/pages/auth/ChangePasswordPage.vue +16 -1
  140. package/template/apps/web/src/pages/configuration/components/ModuleConfigVersionList.vue +1 -0
  141. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationEditor.vue +10 -0
  142. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationWorkbench.vue +37 -6
  143. package/template/apps/web/src/pages/errors/PlatformErrorPage.vue +115 -0
  144. package/template/apps/web/src/pages/errors/PlatformErrorPage.webtest.ts +44 -0
  145. package/template/apps/web/src/pages/errors/error-page-extension.ts +26 -0
  146. package/template/apps/web/src/pages/system/SystemNotificationsPage.vue +136 -68
  147. package/template/apps/web/src/pages/system/SystemNotificationsPage.webtest.ts +224 -12
  148. package/template/apps/web/src/pages/system/audit-log-presenter.ts +86 -0
  149. package/template/apps/web/src/pages/system/audit-time-range.ts +28 -0
  150. package/template/apps/web/src/pages/system/components/AuditActionCell.vue +10 -69
  151. package/template/apps/web/src/pages/system/components/AuditActionCell.webtest.ts +20 -27
  152. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.vue +134 -0
  153. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.webtest.ts +76 -0
  154. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +116 -52
  155. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +52 -15
  156. package/template/apps/web/src/pages/system/components/NotificationHistoryDialog.vue +129 -0
  157. package/template/apps/web/src/pages/system/components/NotificationManagementCard.vue +91 -0
  158. package/template/apps/web/src/pages/system/components/NotificationPublishDialog.vue +121 -0
  159. package/template/apps/web/src/pages/system/components/NotificationPublishForm.vue +162 -1
  160. package/template/apps/web/src/pages/system/components/SystemResourceActions.vue +68 -0
  161. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +74 -0
  162. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +3 -4
  163. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +91 -98
  164. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +68 -0
  165. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +20 -1
  166. package/template/apps/web/src/pages/system/components/SystemResourceTable.webtest.ts +28 -7
  167. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +210 -0
  168. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +112 -0
  169. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +17 -4
  170. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +79 -0
  171. package/template/apps/web/src/pages/system/composables/useNotificationPermissions.ts +14 -0
  172. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +2 -0
  173. package/template/apps/web/src/pages/system/composables/useSystemResource.webtest.ts +15 -2
  174. package/template/apps/web/src/pages/system/resource-config.ts +12 -4
  175. package/template/apps/web/src/router/error-navigation.ts +50 -0
  176. package/template/apps/web/src/router/error-navigation.webtest.ts +106 -0
  177. package/template/apps/web/src/router/index.ts +71 -0
  178. package/template/apps/web/src/router/index.webtest.ts +29 -0
  179. package/template/apps/web/src/router/manifest-routes.webtest.ts +4 -0
  180. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  181. package/template/apps/web/src/services/auth-api.ts +5 -0
  182. package/template/apps/web/src/services/http.ts +43 -6
  183. package/template/apps/web/src/services/http.webtest.ts +70 -0
  184. package/template/apps/web/src/services/notification-api.ts +85 -2
  185. package/template/apps/web/src/services/notification-api.webtest.ts +110 -0
  186. package/template/apps/web/src/services/search-api.ts +39 -0
  187. package/template/apps/web/src/services/system-api.ts +18 -0
  188. package/template/apps/web/vitest.config.ts +1 -0
  189. package/template/docker-compose.yml +66 -0
  190. package/template/docs/architecture/ai-runtime.md +14 -5
  191. package/template/docs/architecture/search-provider-protocol.md +38 -2
  192. package/template/docs/development/custom-error-pages.md +78 -0
  193. package/template/docs/development/custom-login-page.md +3 -3
  194. package/template/docs/platform/agent-runtime.md +32 -6
  195. package/template/docs/platform/audit-reliability.md +46 -2
  196. package/template/docs/platform/module-configuration.md +13 -4
  197. package/template/docs/platform/notifications.md +67 -11
  198. package/template/docs/platform/organization-membership.md +17 -3
  199. package/template/docs/platform/system-crud-api.md +16 -0
  200. package/template/docs/platform/web-system-crud.md +6 -2
  201. package/template/package.json +4 -0
  202. package/template/packages/config/index.test.ts +45 -0
  203. package/template/packages/config/package.json +1 -1
  204. package/template/packages/config/src/platform.ts +169 -48
  205. package/template/packages/database/drizzle/0035_material_nightshade.sql +10 -0
  206. package/template/packages/database/drizzle/0036_hesitant_speed.sql +35 -0
  207. package/template/packages/database/drizzle/0037_absent_nick_fury.sql +2 -0
  208. package/template/packages/database/drizzle/0038_uneven_wasp.sql +38 -0
  209. package/template/packages/database/drizzle/meta/0035_snapshot.json +7331 -0
  210. package/template/packages/database/drizzle/meta/0036_snapshot.json +7436 -0
  211. package/template/packages/database/drizzle/meta/0037_snapshot.json +7457 -0
  212. package/template/packages/database/drizzle/meta/0038_snapshot.json +7749 -0
  213. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  214. package/template/packages/database/package.json +1 -1
  215. package/template/packages/database/src/schema/identity.ts +7 -0
  216. package/template/packages/database/src/schema/notifications.ts +135 -0
  217. package/template/packages/database/src/schema-notification-delivery.test.ts +54 -0
  218. package/template/packages/database/src/schema-workflow-notification.test.ts +40 -1
  219. package/template/packages/modules/package.json +1 -1
  220. package/template/packages/modules/src/system-api-permissions.ts +54 -0
  221. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  222. package/template/packages/modules/src/system-features.ts +1 -1
  223. package/template/packages/modules/src/system-module-catalog.ts +2 -1
  224. package/template/packages/modules/src/system-modules.test.ts +13 -1
  225. package/template/packages/modules/src/system-modules.ts +12 -0
  226. package/template/packages/modules/src/system-permissions.ts +9 -5
  227. package/template/packages/server-sdk/package.json +1 -1
  228. package/template/packages/shared/package.json +1 -1
  229. package/template/packages/shared/src/audit.ts +25 -0
  230. package/template/packages/shared/src/module-configuration.ts +5 -0
  231. package/template/packages/shared/src/notification.ts +92 -1
  232. package/template/packages/shared/src/password.ts +7 -2
  233. package/template/packages/shared/src/platform-settings.ts +2 -0
  234. package/template/packages/storage/package.json +1 -1
  235. package/template/packages/storage/src/upload-service.test.ts +16 -0
  236. package/template/packages/storage/src/upload-service.ts +5 -1
  237. package/template/scripts/notification-postgres-regression.ts +27 -0
@@ -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
+ }
@@ -0,0 +1,225 @@
1
+ import { randomUUIDv7 } from "bun";
2
+ import type {
3
+ AuditActionType,
4
+ NotificationInboxFilters,
5
+ UpdateNotificationPreferenceInput,
6
+ PublishNotificationInput,
7
+ UpdateNotificationInput,
8
+ } from "@southwind-ai/shared";
9
+ import { auditActor, type RequestGuardContext } from "../guards.js";
10
+
11
+ export type NotificationRouteContext = {
12
+ body?: unknown;
13
+ guardContext: RequestGuardContext;
14
+ params: Record<string, string | undefined>;
15
+ query: Record<string, string | undefined>;
16
+ };
17
+
18
+ export function parsePublishInput(
19
+ body: unknown,
20
+ ): PublishNotificationInput | Response {
21
+ const content = parseContentInput(body);
22
+ if (content instanceof Response) return content;
23
+ const value = body as Record<string, unknown>;
24
+ const scheduledFor = optionalText(value.scheduledFor);
25
+ if (scheduledFor && !validFutureDate(scheduledFor)) {
26
+ return invalidNotification("scheduledFor 必须是未来的 ISO 时间");
27
+ }
28
+ const recipientUserIds = stringArray(value.recipientUserIds);
29
+ const recipientRoleCodes = stringArray(value.recipientRoleCodes);
30
+ const recipientDepartmentIds = stringArray(value.recipientDepartmentIds);
31
+ const deliveryChannels = stringArray(value.deliveryChannels);
32
+ if (
33
+ recipientUserIds instanceof Response ||
34
+ recipientRoleCodes instanceof Response ||
35
+ recipientDepartmentIds instanceof Response ||
36
+ deliveryChannels instanceof Response
37
+ ) {
38
+ return invalidNotification("通知受众与渠道必须是字符串数组");
39
+ }
40
+ if (
41
+ deliveryChannels.some((channel) => channel !== "email" && channel !== "sms")
42
+ ) {
43
+ return invalidNotification("deliveryChannels 仅支持 email 或 sms");
44
+ }
45
+ if (
46
+ [recipientUserIds, recipientRoleCodes, recipientDepartmentIds].some(
47
+ (values) => values.length > 100,
48
+ )
49
+ ) {
50
+ return invalidNotification("单次发布的每类受众最多 100 项");
51
+ }
52
+ return {
53
+ ...content,
54
+ scheduledFor,
55
+ recipientUserIds,
56
+ recipientRoleCodes,
57
+ recipientDepartmentIds,
58
+ includeChildDepartments: value.includeChildDepartments === true,
59
+ deliveryChannels: deliveryChannels.filter(
60
+ (channel): channel is "email" | "sms" =>
61
+ channel === "email" || channel === "sms",
62
+ ),
63
+ };
64
+ }
65
+
66
+ export function parseUpdateInput(
67
+ body: unknown,
68
+ ): UpdateNotificationInput | Response {
69
+ return parseContentInput(body);
70
+ }
71
+
72
+ export function parsePreferenceInput(
73
+ body: unknown,
74
+ ): UpdateNotificationPreferenceInput | Response {
75
+ const value =
76
+ body && typeof body === "object" ? (body as Record<string, unknown>) : {};
77
+ const enabledChannels = stringArray(value.enabledChannels);
78
+ const disabledCategories = stringArray(value.disabledCategories);
79
+ if (
80
+ enabledChannels instanceof Response ||
81
+ disabledCategories instanceof Response
82
+ ) {
83
+ return invalidNotification("通知偏好必须是字符串数组");
84
+ }
85
+ if (
86
+ enabledChannels.some(
87
+ (channel) => channel !== "email" && channel !== "sms",
88
+ ) ||
89
+ disabledCategories.length > 100 ||
90
+ disabledCategories.some((category) => category.length > 40)
91
+ ) {
92
+ return invalidNotification("通知偏好包含不支持的渠道或分类");
93
+ }
94
+ return {
95
+ enabledChannels: enabledChannels.filter(
96
+ (channel): channel is "email" | "sms" =>
97
+ channel === "email" || channel === "sms",
98
+ ),
99
+ disabledCategories,
100
+ };
101
+ }
102
+
103
+ export function parseInboxFilters(
104
+ query: Record<string, string | undefined>,
105
+ ): NotificationInboxFilters | Response {
106
+ const q = query.q?.trim();
107
+ const category = query.category?.trim();
108
+ if (q && q.length > 200) return invalidFilter("q 最多 200 个字符");
109
+ if (category && category.length > 40)
110
+ return invalidFilter("category 最多 40 个字符");
111
+ if (query.read && query.read !== "read" && query.read !== "unread") {
112
+ return invalidFilter("read 仅支持 read 或 unread");
113
+ }
114
+ if (
115
+ query.favorite !== undefined &&
116
+ query.favorite !== "" &&
117
+ query.favorite !== "true" &&
118
+ query.favorite !== "false"
119
+ ) {
120
+ return invalidFilter("favorite 仅支持 true 或 false");
121
+ }
122
+ return {
123
+ q: q || undefined,
124
+ category: category || undefined,
125
+ read:
126
+ query.read === "read" || query.read === "unread" ? query.read : undefined,
127
+ favorite:
128
+ query.favorite === "true"
129
+ ? true
130
+ : query.favorite === "false"
131
+ ? false
132
+ : undefined,
133
+ };
134
+ }
135
+
136
+ export function notificationEvent(
137
+ context: NotificationRouteContext,
138
+ action: AuditActionType,
139
+ id: string,
140
+ ) {
141
+ return {
142
+ id: randomUUIDv7(),
143
+ action,
144
+ outcome: "success" as const,
145
+ actor: auditActor(context.guardContext),
146
+ target: { type: "notification", id },
147
+ requestId: context.guardContext.requestId,
148
+ occurredAt: new Date().toISOString(),
149
+ };
150
+ }
151
+
152
+ export function notificationNotFound(id: string) {
153
+ return Response.json(
154
+ { error: "NOT_FOUND", resource: "notification", id },
155
+ { status: 404 },
156
+ );
157
+ }
158
+
159
+ function parseContentInput(body: unknown): UpdateNotificationInput | Response {
160
+ const value =
161
+ body && typeof body === "object" ? (body as Record<string, unknown>) : {};
162
+ const input = {
163
+ title: text(value.title),
164
+ summary: text(value.summary),
165
+ content: text(value.content),
166
+ category: text(value.category),
167
+ };
168
+ if (!input.title || !input.summary || !input.content || !input.category) {
169
+ return Response.json(
170
+ {
171
+ error: "INVALID_NOTIFICATION",
172
+ message: "标题、摘要、正文和分类均不能为空",
173
+ },
174
+ { status: 400 },
175
+ );
176
+ }
177
+ if (
178
+ input.title.length > 120 ||
179
+ input.summary.length > 240 ||
180
+ input.content.length > 10_000 ||
181
+ input.category.length > 40
182
+ ) {
183
+ return Response.json(
184
+ { error: "INVALID_NOTIFICATION", message: "通知内容超过长度限制" },
185
+ { status: 400 },
186
+ );
187
+ }
188
+ return input;
189
+ }
190
+
191
+ function invalidFilter(message: string) {
192
+ return Response.json(
193
+ { error: "INVALID_NOTIFICATION_FILTER", message },
194
+ { status: 400 },
195
+ );
196
+ }
197
+
198
+ function invalidNotification(message: string) {
199
+ return Response.json(
200
+ { error: "INVALID_NOTIFICATION", message },
201
+ { status: 400 },
202
+ );
203
+ }
204
+
205
+ function text(value: unknown) {
206
+ return typeof value === "string" ? value.trim() : "";
207
+ }
208
+
209
+ function optionalText(value: unknown) {
210
+ const result = text(value);
211
+ return result || undefined;
212
+ }
213
+
214
+ function stringArray(value: unknown): string[] | Response {
215
+ if (value === undefined) return [];
216
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
217
+ return invalidNotification("字段必须是字符串数组");
218
+ }
219
+ return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
220
+ }
221
+
222
+ function validFutureDate(value: string) {
223
+ const timestamp = Date.parse(value);
224
+ return Number.isFinite(timestamp) && timestamp > Date.now();
225
+ }