@allbluecn/web-app 0.4.22 → 0.5.1

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 (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -1,15 +1,19 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  import { prisma } from '@allbluecn/database'
3
3
  import type {
4
- CategoryPreferences,
4
+ CategoryFrequencies,
5
5
  NotificationCategory,
6
6
  NotificationItem,
7
7
  NotificationPreferences,
8
8
  } from '@allbluecn/shared'
9
- import { isInDndWindow, DEFAULT_CATEGORIES } from '@allbluecn/shared'
9
+ import {
10
+ isInDndWindow,
11
+ normalizeCategoryFrequencies,
12
+ DEFAULT_CATEGORIES,
13
+ } from '@allbluecn/shared'
10
14
  import { notificationBus } from './event-bus'
11
15
 
12
- export { DEFAULT_CATEGORIES, isInDndWindow } from '@allbluecn/shared'
16
+ export { DEFAULT_CATEGORIES, isInDndWindow, normalizeCategoryFrequencies } from '@allbluecn/shared'
13
17
 
14
18
  export interface CreateNotificationInput {
15
19
  userId: string
@@ -18,13 +22,36 @@ export interface CreateNotificationInput {
18
22
  title: string
19
23
  body?: string
20
24
  link?: string
25
+ /** 绕过 frequency 判定并强制实时推送(仅 digest 摘要生成使用) */
26
+ force?: boolean
27
+ }
28
+
29
+ /** 服务端直出的类别中文名(与 zh locale tabs 保持一致) */
30
+ const CATEGORY_LABELS: Record<NotificationCategory, string> = {
31
+ team: '团队协作',
32
+ collaboration: '内容协作',
33
+ system: '系统公告',
34
+ billing: '账单订阅',
35
+ security: '安全告警',
36
+ }
37
+
38
+ const DIGEST_TYPE: Record<'digest-daily' | 'digest-weekly', string> = {
39
+ 'digest-daily': 'digest.daily',
40
+ 'digest-weekly': 'digest.weekly',
41
+ }
42
+
43
+ const DIGEST_MIN_INTERVAL_MS: Record<'digest-daily' | 'digest-weekly', number> = {
44
+ 'digest-daily': 24 * 60 * 60 * 1000,
45
+ 'digest-weekly': 7 * 24 * 60 * 60 * 1000,
21
46
  }
22
47
 
48
+ const DIGEST_SUMMARY_ITEMS = 3
49
+
23
50
  export async function getOrCreatePreferences(userId: string): Promise<NotificationPreferences> {
24
51
  const existing = await prisma.notificationPreference.findUnique({ where: { userId } })
25
52
  if (existing) {
26
53
  return {
27
- categories: { ...DEFAULT_CATEGORIES, ...(existing.categories as object) } as CategoryPreferences,
54
+ categories: normalizeCategoryFrequencies(existing.categories),
28
55
  dndEnabled: existing.dndEnabled,
29
56
  dndStart: existing.dndStart,
30
57
  dndEnd: existing.dndEnd,
@@ -52,35 +79,118 @@ export async function getOrCreatePreferences(userId: string): Promise<Notificati
52
79
  return defaults
53
80
  }
54
81
 
55
- /** fire-and-forget:任何失败只记日志不抛出,不阻断业务主流程 */
82
+ async function insertNotification(
83
+ input: CreateNotificationInput
84
+ ): Promise<NotificationItem | null> {
85
+ const created = await prisma.notification.create({
86
+ data: {
87
+ userId: input.userId,
88
+ category: input.category,
89
+ type: input.type,
90
+ title: input.title,
91
+ body: input.body,
92
+ link: input.link,
93
+ },
94
+ })
95
+ return {
96
+ id: created.id,
97
+ category: created.category as NotificationCategory,
98
+ type: created.type,
99
+ title: created.title,
100
+ body: created.body,
101
+ link: created.link,
102
+ readAt: created.readAt ? created.readAt.toISOString() : null,
103
+ createdAt: created.createdAt.toISOString(),
104
+ }
105
+ }
106
+
107
+ /**
108
+ * fire-and-forget:任何失败只记日志不抛出,不阻断业务主流程。
109
+ * frequency 判定:off 不投递;realtime 入库 + 实时推送;digest-* 仅入库(由 deliverDueDigests 汇总推送)。
110
+ */
56
111
  export async function createNotification(input: CreateNotificationInput): Promise<void> {
57
112
  try {
58
113
  const prefs = await getOrCreatePreferences(input.userId)
59
- if (!prefs.categories[input.category]) return
114
+ const frequency = prefs.categories[input.category]
115
+ if (!input.force && frequency === 'off') return
60
116
 
61
- const created = await prisma.notification.create({
62
- data: {
63
- userId: input.userId,
64
- category: input.category,
65
- type: input.type,
66
- title: input.title,
67
- body: input.body,
68
- link: input.link,
69
- },
70
- })
117
+ const item = await insertNotification(input)
118
+ if (!item) return
71
119
 
72
- const item: NotificationItem = {
73
- id: created.id,
74
- category: created.category as NotificationCategory,
75
- type: created.type,
76
- title: created.title,
77
- body: created.body,
78
- link: created.link,
79
- readAt: created.readAt ? created.readAt.toISOString() : null,
80
- createdAt: created.createdAt.toISOString(),
120
+ if (input.force || frequency === 'realtime') {
121
+ notificationBus.emit(input.userId, { kind: 'notification', notification: item })
81
122
  }
82
- notificationBus.emit(input.userId, { kind: 'notification', notification: item })
83
123
  } catch (e) {
84
124
  console.error('[notifications] create failed:', e instanceof Error ? e.message : 'unknown')
85
125
  }
86
- }
126
+ }
127
+
128
+ function formatDigestBody(titles: string[], total: number): string {
129
+ const lines = titles.slice(0, DIGEST_SUMMARY_ITEMS).map((t) => `· ${t}`)
130
+ if (total > DIGEST_SUMMARY_ITEMS) {
131
+ lines.push(`等 ${total} 条通知`)
132
+ }
133
+ return lines.join('\n')
134
+ }
135
+
136
+ /**
137
+ * 检查并投递到期的 digest(摘要)通知。在用户建立 SSE 连接时调用(fire-and-forget):
138
+ * 以同类别最近一条同频率摘要的 createdAt 为水位线,积压通知数 > 0 且距上次摘要超过
139
+ * 频率周期时,按类别生成一条摘要通知并实时推送。
140
+ */
141
+ export async function deliverDueDigests(userId: string): Promise<void> {
142
+ try {
143
+ const prefs = await getOrCreatePreferences(userId)
144
+ const digestCats = (Object.keys(prefs.categories) as NotificationCategory[]).filter(
145
+ (c) => prefs.categories[c] === 'digest-daily' || prefs.categories[c] === 'digest-weekly'
146
+ )
147
+ for (const category of digestCats) {
148
+ const frequency = prefs.categories[category] as 'digest-daily' | 'digest-weekly'
149
+ const type = DIGEST_TYPE[frequency]
150
+
151
+ const lastDigest = await prisma.notification.findFirst({
152
+ where: { userId, category, type },
153
+ orderBy: { createdAt: 'desc' },
154
+ select: { createdAt: true },
155
+ })
156
+ if (lastDigest && Date.now() - lastDigest.createdAt.getTime() < DIGEST_MIN_INTERVAL_MS[frequency]) {
157
+ continue
158
+ }
159
+
160
+ const since = lastDigest?.createdAt ?? new Date(0)
161
+ const pendingCount = await prisma.notification.count({
162
+ where: {
163
+ userId,
164
+ category,
165
+ type: { not: { startsWith: 'digest.' } },
166
+ createdAt: { gt: since },
167
+ },
168
+ })
169
+ if (pendingCount === 0) continue
170
+
171
+ const pending = await prisma.notification.findMany({
172
+ where: {
173
+ userId,
174
+ category,
175
+ type: { not: { startsWith: 'digest.' } },
176
+ createdAt: { gt: since },
177
+ },
178
+ orderBy: { createdAt: 'desc' },
179
+ select: { title: true },
180
+ take: DIGEST_SUMMARY_ITEMS,
181
+ })
182
+
183
+ const periodLabel = frequency === 'digest-daily' ? '今日' : '本周'
184
+ await createNotification({
185
+ userId,
186
+ category,
187
+ type,
188
+ title: `${periodLabel}${CATEGORY_LABELS[category]}有 ${pendingCount} 条新通知`,
189
+ body: formatDigestBody(pending.map((n) => n.title), pendingCount),
190
+ force: true,
191
+ })
192
+ }
193
+ } catch (e) {
194
+ console.error('[notifications] digest failed:', e instanceof Error ? e.message : 'unknown')
195
+ }
196
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@tanstack/react-start", () => ({
21
+ createServerFn: (config: any) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema;
26
+ return this;
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data;
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data;
33
+ }
34
+ return fn({ data });
35
+ };
36
+ return wrapped;
37
+ },
38
+ };
39
+ return api;
40
+ },
41
+ }));
42
+
43
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
44
+
45
+ const mocks = vi.hoisted(() => ({
46
+ pkbFindMany: vi.fn(),
47
+ pkbDeleteMany: vi.fn(),
48
+ pkbCreateMany: vi.fn(),
49
+ kbFindMany: vi.fn(),
50
+ projectFindMany: vi.fn(),
51
+ kbFindFirst: vi.fn(),
52
+ transaction: vi.fn(),
53
+ currentSession: vi.fn(),
54
+ }));
55
+
56
+ vi.mock("@allbluecn/database", () => {
57
+ const prisma = {
58
+ projectKnowledgeBase: {
59
+ findMany: mocks.pkbFindMany,
60
+ deleteMany: mocks.pkbDeleteMany,
61
+ createMany: mocks.pkbCreateMany,
62
+ },
63
+ knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
64
+ project: { findMany: mocks.projectFindMany },
65
+ $transaction: mocks.transaction,
66
+ };
67
+ return { prisma, Prisma: {} };
68
+ });
69
+
70
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: mocks.currentSession }));
71
+ vi.mock("@/server/permissions", () => ({ requirePermission: vi.fn().mockResolvedValue(undefined) }));
72
+ vi.mock("@/server/audit", () => ({ auditLog: vi.fn() }));
73
+
74
+ import { setProjectKbsFn, listProjectKbsFn } from "../project";
75
+ import { setKbProjectsFn, listKbProjectsFn } from "../knowledge";
76
+
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
80
+ });
81
+
82
+ describe("setProjectKbsFn 校验", () => {
83
+ it("仅接受 team 可见性 KB,非 team 抛错且不写库", async () => {
84
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]); // 请求 [kb1(team), kb2(general)] 但只查回 kb1
85
+ await expect(
86
+ setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } }),
87
+ ).rejects.toMatchObject({ message: expect.stringContaining("仅允许团队可见") });
88
+ expect(mocks.transaction).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("全量替换:删差集 + 增新增(事务)", async () => {
92
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]);
93
+ mocks.transaction.mockImplementation(async (fn: (tx: any) => any) =>
94
+ fn({
95
+ projectKnowledgeBase: {
96
+ deleteMany: mocks.pkbDeleteMany,
97
+ createMany: mocks.pkbCreateMany,
98
+ },
99
+ }),
100
+ );
101
+ await setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } });
102
+ expect(mocks.pkbDeleteMany).toHaveBeenCalledWith({
103
+ where: { projectId: "p1", kbId: { notIn: ["kb1", "kb2"] } },
104
+ });
105
+ expect(mocks.pkbCreateMany).toHaveBeenCalledWith({
106
+ data: [
107
+ { projectId: "p1", kbId: "kb1" },
108
+ { projectId: "p1", kbId: "kb2" },
109
+ ],
110
+ skipDuplicates: true,
111
+ });
112
+ });
113
+ });
114
+
115
+ describe("setKbProjectsFn 校验", () => {
116
+ it("general KB 拒绝关联", async () => {
117
+ mocks.kbFindFirst.mockResolvedValue({ id: "kbG", visibility: "general" });
118
+ await expect(
119
+ setKbProjectsFn({ data: { kbId: "kbG", projectIds: ["p1"] } }),
120
+ ).rejects.toMatchObject({ message: expect.stringContaining("通用知识库") });
121
+ });
122
+
123
+ it("private KB 拒绝关联", async () => {
124
+ mocks.kbFindFirst.mockResolvedValue({ id: "kbP", visibility: "private" });
125
+ await expect(
126
+ setKbProjectsFn({ data: { kbId: "kbP", projectIds: ["p1"] } }),
127
+ ).rejects.toMatchObject({ message: expect.stringContaining("私有知识库") });
128
+ });
129
+ });
130
+
131
+ describe("listProjectKbsFn / listKbProjectsFn", () => {
132
+ it("项目侧返回 KB 概要", async () => {
133
+ mocks.pkbFindMany.mockResolvedValue([
134
+ { kb: { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" } },
135
+ ]);
136
+ const result = await listProjectKbsFn({ data: { projectId: "p1" } });
137
+ expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
138
+ });
139
+
140
+ it("KB 侧返回项目概要", async () => {
141
+ mocks.pkbFindMany.mockResolvedValue([
142
+ { project: { id: "p1", name: "项目A", icon: "folder" } },
143
+ ]);
144
+ const result = await listKbProjectsFn({ data: { kbId: "kb1" } });
145
+ expect(result[0]).toMatchObject({ id: "p1", name: "项目A" });
146
+ });
147
+ });
@@ -40,8 +40,9 @@ vi.mock('@allbluecn/database', () => ({
40
40
  update: vi.fn(),
41
41
  count: vi.fn(),
42
42
  deleteMany: vi.fn(),
43
+ delete: vi.fn(),
43
44
  },
44
- teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn() },
45
+ teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
45
46
  $transaction: vi.fn(),
46
47
  },
47
48
  }))
@@ -68,16 +69,18 @@ import {
68
69
  refreshTeamInvitationLinkFn,
69
70
  listTeamInvitationsFn,
70
71
  clearExpiredInvitationsFn,
72
+ deleteTeamInvitationFn,
73
+ deleteTeamJoinRequestFn,
71
74
  } from '../team'
72
75
 
73
76
  describe('refreshTeamInvitationLinkFn', () => {
74
77
  beforeEach(() => vi.clearAllMocks())
75
78
 
76
- it('生成新链接,删除旧链接', async () => {
79
+ it('生成新链接,旧链接置 revoked 保留申请历史', async () => {
77
80
  vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
78
81
  const tx = {
79
82
  teamInvitation: {
80
- deleteMany: vi.fn().mockResolvedValue({}),
83
+ updateMany: vi.fn().mockResolvedValue({}),
81
84
  create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'abc123' }),
82
85
  },
83
86
  teamJoinRequest: {
@@ -97,7 +100,7 @@ describe('refreshTeamInvitationLinkFn', () => {
97
100
  vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
98
101
  const tx = {
99
102
  teamInvitation: {
100
- deleteMany: vi.fn().mockResolvedValue({}),
103
+ updateMany: vi.fn().mockResolvedValue({}),
101
104
  create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'xyz' }),
102
105
  },
103
106
  teamJoinRequest: {
@@ -114,7 +117,7 @@ describe('refreshTeamInvitationLinkFn', () => {
114
117
  vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
115
118
  const tx = {
116
119
  teamInvitation: {
117
- deleteMany: vi.fn().mockResolvedValue({}),
120
+ updateMany: vi.fn().mockResolvedValue({}),
118
121
  create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'tok' }),
119
122
  },
120
123
  teamJoinRequest: {
@@ -199,15 +202,78 @@ describe('createTeamInvitationFn link 邀请', () => {
199
202
  describe('clearExpiredInvitationsFn', () => {
200
203
  beforeEach(() => vi.clearAllMocks())
201
204
 
202
- it('清除所有过期邀请', async () => {
205
+ it('清除所有过期邮箱邀请(链接行保留以维持申请历史)', async () => {
203
206
  vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 2 } as never)
204
207
  const result = await clearExpiredInvitationsFn({ data: {} })
205
208
  expect(result.success).toBe(true)
206
209
  expect(result.count).toBe(2)
207
210
  expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(
208
211
  expect.objectContaining({
209
- where: expect.objectContaining({ status: 'expired' }),
212
+ where: expect.objectContaining({ status: 'expired', channel: 'email' }),
210
213
  }),
211
214
  )
212
215
  })
213
216
  })
217
+
218
+ describe('deleteTeamInvitationFn', () => {
219
+ beforeEach(() => vi.clearAllMocks())
220
+
221
+ it('删除自己的 email 邀请记录', async () => {
222
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
223
+ id: 'inv1', ownerId: 'owner1', channel: 'email', email: 'x@y.com',
224
+ } as never)
225
+ vi.mocked(prisma.teamInvitation.delete).mockResolvedValue({} as never)
226
+
227
+ const result = await deleteTeamInvitationFn({ data: { invitationId: 'inv1' } })
228
+
229
+ expect(result.success).toBe(true)
230
+ expect(prisma.teamInvitation.delete).toHaveBeenCalledWith({ where: { id: 'inv1' } })
231
+ })
232
+
233
+ it('link 渠道邀请禁止删除(防级联删申请)', async () => {
234
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
235
+ id: 'inv2', ownerId: 'owner1', channel: 'link',
236
+ } as never)
237
+
238
+ await expect(
239
+ deleteTeamInvitationFn({ data: { invitationId: 'inv2' } }),
240
+ ).rejects.toMatchObject({ message: expect.stringContaining('不可删除') })
241
+ expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
242
+ })
243
+
244
+ it('他人邀请不可删除', async () => {
245
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
246
+ id: 'inv3', ownerId: 'someone-else', channel: 'email',
247
+ } as never)
248
+
249
+ await expect(
250
+ deleteTeamInvitationFn({ data: { invitationId: 'inv3' } }),
251
+ ).rejects.toMatchObject({ message: '邀请不存在' })
252
+ expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
253
+ })
254
+ })
255
+
256
+ describe('deleteTeamJoinRequestFn', () => {
257
+ beforeEach(() => vi.clearAllMocks())
258
+
259
+ it('删除自己的申请记录', async () => {
260
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({
261
+ id: 'jr1', ownerId: 'owner1', email: 'a@b.com', status: 'rejected',
262
+ } as never)
263
+ vi.mocked(prisma.teamJoinRequest.delete).mockResolvedValue({} as never)
264
+
265
+ const result = await deleteTeamJoinRequestFn({ data: { requestId: 'jr1' } })
266
+
267
+ expect(result.success).toBe(true)
268
+ expect(prisma.teamJoinRequest.delete).toHaveBeenCalledWith({ where: { id: 'jr1' } })
269
+ })
270
+
271
+ it('他人申请不可删除', async () => {
272
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
273
+
274
+ await expect(
275
+ deleteTeamJoinRequestFn({ data: { requestId: 'jrX' } }),
276
+ ).rejects.toMatchObject({ message: '申请不存在' })
277
+ expect(prisma.teamJoinRequest.delete).not.toHaveBeenCalled()
278
+ })
279
+ })
@@ -246,9 +246,9 @@ describe('getTeamInvitationByTokenFn(link 渠道)', () => {
246
246
  describe('refreshTeamInvitationLinkFn', () => {
247
247
  beforeEach(() => vi.clearAllMocks())
248
248
 
249
- it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接删除,新链接创建', async () => {
249
+ it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接置 revoked 保留历史,新链接创建', async () => {
250
250
  vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
251
- vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 1 } as never)
251
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 1 } as never)
252
252
  vi.mocked(prisma.teamInvitation.create).mockResolvedValue({ id: 'inv2', token: 'NEW' } as never)
253
253
 
254
254
  const res = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 7 } })
@@ -258,9 +258,11 @@ describe('refreshTeamInvitationLinkFn', () => {
258
258
  where: expect.objectContaining({ ownerId: 'u2', status: 'pending' }),
259
259
  data: expect.objectContaining({ status: 'invalidated' }),
260
260
  }))
261
- expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(expect.objectContaining({
261
+ expect(prisma.teamInvitation.updateMany).toHaveBeenCalledWith(expect.objectContaining({
262
262
  where: { ownerId: 'u2', channel: 'link', status: { in: ['pending', 'pending_approval'] } },
263
+ data: { status: 'revoked' },
263
264
  }))
265
+ expect(prisma.teamInvitation.deleteMany).not.toHaveBeenCalled()
264
266
  expect(prisma.teamInvitation.create).toHaveBeenCalled()
265
267
  })
266
268
  })
@@ -25,6 +25,12 @@ import { deleteEmbeddings } from '@/lib/pgvector'
25
25
  import { requirePermission } from '@/server/permissions'
26
26
  import { auditLog } from '@/server/audit'
27
27
 
28
+ /** 读权限放宽:自己的 KB ∪ 他人 team/general KB */
29
+ const READABLE_KB = (userId: string) => ({
30
+ isDeleted: false,
31
+ OR: [{ userId }, { visibility: { in: ['team', 'general'] } }],
32
+ })
33
+
28
34
  export const listKnowledgeFn = createServerFn({ method: 'GET' })
29
35
  .validator(z.object({}).optional())
30
36
  .handler(async () => {
@@ -51,7 +57,7 @@ const createSchema = z.object({
51
57
  description: z.string().max(200).optional(),
52
58
  icon: z.string().default('notebook'),
53
59
  tags: z.array(z.string()).default([]),
54
- visibility: z.enum(['private', 'team']).default('private'),
60
+ visibility: z.enum(['private', 'team', 'general']).default('private'),
55
61
  })
56
62
 
57
63
  export const createKnowledgeFn = createServerFn({ method: 'POST' })
@@ -95,7 +101,7 @@ export const getKnowledgeDetailFn = createServerFn({ method: 'GET' })
95
101
  const request = getRequest()
96
102
  const session = await requireAuth(request)
97
103
  const kb = await prisma.knowledgeBase.findFirst({
98
- where: { id: data.id, userId: session.user!.id!, isDeleted: false },
104
+ where: { id: data.id, ...READABLE_KB(session.user!.id!) },
99
105
  select: { id: true, name: true, description: true, icon: true, tags: true, visibility: true, createdAt: true, updatedAt: true },
100
106
  })
101
107
  if (!kb) throw new Error('未找到或无权限')
@@ -130,7 +136,7 @@ const updateSchema = z.object({
130
136
  description: z.string().max(500).optional(),
131
137
  icon: z.string().optional(),
132
138
  tags: z.array(z.string()).optional(),
133
- visibility: z.enum(['private', 'team']).optional(),
139
+ visibility: z.enum(['private', 'team', 'general']).optional(),
134
140
  })
135
141
 
136
142
  export const updateKnowledgeFn = createServerFn({ method: 'POST' })
@@ -202,7 +208,7 @@ export const createDocumentFn = createServerFn({ method: 'POST' })
202
208
  const userId = session.user!.id!
203
209
  await requirePermission(userId, 'knowledge', 'write')
204
210
  const kb = await prisma.knowledgeBase.findFirst({
205
- where: { id: data.kbId, userId, isDeleted: false },
211
+ where: { id: data.kbId, ...READABLE_KB(session.user!.id!) },
206
212
  select: { id: true },
207
213
  })
208
214
  if (!kb) throw new Error('未找到或无权限')
@@ -581,3 +587,43 @@ export const getDocumentPreviewFn = createServerFn({ method: 'GET' })
581
587
  } : null,
582
588
  } satisfies DocumentPreviewData
583
589
  })
590
+
591
+ export const listKbProjectsFn = createServerFn({ method: 'GET' })
592
+ .validator(z.object({ kbId: z.string() }))
593
+ .handler(async ({ data }) => {
594
+ const request = getRequest()
595
+ const session = await requireAuth(request)
596
+ const rows = await prisma.projectKnowledgeBase.findMany({
597
+ where: { kbId: data.kbId },
598
+ select: { project: { select: { id: true, name: true, icon: true } } },
599
+ orderBy: { createdAt: 'asc' },
600
+ })
601
+ return rows.map((r) => r.project)
602
+ })
603
+
604
+ export const setKbProjectsFn = createServerFn({ method: 'POST' })
605
+ .validator(z.object({ kbId: z.string(), projectIds: z.array(z.string()) }))
606
+ .handler(async ({ data }) => {
607
+ const request = getRequest()
608
+ const session = await requireAuth(request)
609
+ const userId = session.user!.id!
610
+ const kb = await prisma.knowledgeBase.findFirst({
611
+ where: { id: data.kbId, userId, isDeleted: false },
612
+ select: { visibility: true },
613
+ })
614
+ if (!kb) throw new Error('知识库不存在')
615
+ if (kb.visibility === 'general') throw new Error('通用知识库对所有项目可见,无需关联')
616
+ if (kb.visibility === 'private') throw new Error('私有知识库不可关联项目,请先切换为团队可见')
617
+ await prisma.$transaction(async (tx) => {
618
+ await tx.projectKnowledgeBase.deleteMany({
619
+ where: { kbId: data.kbId, projectId: { notIn: data.projectIds } },
620
+ })
621
+ if (data.projectIds.length > 0) {
622
+ await tx.projectKnowledgeBase.createMany({
623
+ data: data.projectIds.map((projectId) => ({ kbId: data.kbId, projectId })),
624
+ skipDuplicates: true,
625
+ })
626
+ }
627
+ })
628
+ return { ok: true }
629
+ })
@@ -1,8 +1,20 @@
1
1
  import { createServerFn } from '@tanstack/react-start'
2
- import { getRequest } from '@tanstack/react-start/server'
2
+ import { getRequest, setCookie } from '@tanstack/react-start/server'
3
3
  import { detectLocale } from '@/lib/i18n/detect'
4
4
 
5
+ /**
6
+ * 检测用户语言并固化为 lng cookie:
7
+ * 首次访问(无 cookie)时,SSR 依据 querystring/accept-language 检测出的语言
8
+ * 写入 cookie,保证客户端 i18n(仅读 querystring/cookie)与服务端同源一致,
9
+ * 避免 hydration mismatch。
10
+ */
5
11
  export const getLocaleFn = createServerFn({ method: 'GET' }).handler(async () => {
6
12
  const request = getRequest()
7
- return detectLocale(request, null)
8
- })
13
+ const locale = detectLocale(request, null)
14
+ setCookie('lng', locale, {
15
+ path: '/',
16
+ maxAge: 60 * 60 * 24 * 365,
17
+ sameSite: 'lax',
18
+ })
19
+ return locale
20
+ })
@@ -6,10 +6,11 @@ import { prisma } from '@allbluecn/database'
6
6
  import { requireAuth } from '@/server/auth-helpers'
7
7
  import {
8
8
  NOTIFICATION_CATEGORIES,
9
+ NOTIFICATION_FREQUENCIES,
9
10
  type NotificationCategory,
10
- type CategoryPreferences,
11
11
  type NotificationItem,
12
12
  type NotificationListResult,
13
+ type CategoryFrequencies,
13
14
  } from '@allbluecn/shared'
14
15
  import {
15
16
  getOrCreatePreferences,
@@ -96,6 +97,16 @@ export const deleteNotificationFn = createServerFn({ method: 'POST' })
96
97
  return { success: true }
97
98
  })
98
99
 
100
+ export const clearNotificationsFn = createServerFn({ method: 'POST' })
101
+ .validator(z.object({}).optional())
102
+ .handler(async () => {
103
+ const request = getRequest()
104
+ const session = await requireAuth(request)
105
+ const userId = session.user!.id!
106
+ const result = await prisma.notification.deleteMany({ where: { userId } })
107
+ return { success: true, count: result.count }
108
+ })
109
+
99
110
  export const getUnreadCountFn = createServerFn({ method: 'GET' })
100
111
  .validator(z.object({}).optional())
101
112
  .handler(async () => {
@@ -117,7 +128,7 @@ export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
117
128
  })
118
129
 
119
130
  const preferencesSchema = z.object({
120
- categories: z.record(z.string(), z.boolean()),
131
+ categories: z.record(z.string(), z.enum(NOTIFICATION_FREQUENCIES)),
121
132
  dndEnabled: z.boolean(),
122
133
  dndStart: z.string().regex(/^\d{2}:\d{2}$/),
123
134
  dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
@@ -131,9 +142,12 @@ export const updateNotificationPreferencesFn = createServerFn({
131
142
  const request = getRequest()
132
143
  const session = await requireAuth(request)
133
144
  const userId = session.user!.id!
134
- const categories = { ...DEFAULT_CATEGORIES } as CategoryPreferences
145
+ const categories = { ...DEFAULT_CATEGORIES } as CategoryFrequencies
135
146
  for (const c of NOTIFICATION_CATEGORIES) {
136
- if (typeof data.categories[c] === 'boolean') categories[c] = data.categories[c]
147
+ const v = data.categories[c]
148
+ if ((NOTIFICATION_FREQUENCIES as readonly string[]).includes(v)) {
149
+ categories[c] = v as CategoryFrequencies[NotificationCategory]
150
+ }
137
151
  }
138
152
  await prisma.notificationPreference.upsert({
139
153
  where: { userId },