@allbluecn/web-app 0.4.21 → 0.5.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 (84) hide show
  1. package/package.json +25 -33
  2. package/src/components/command/command-palette.tsx +1 -1
  3. package/src/components/knowledge/knowledge-toolbar.tsx +29 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  6. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  7. package/src/components/notifications/settings-notifications.tsx +198 -0
  8. package/src/components/notifications/team-notifications.tsx +343 -0
  9. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  10. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  11. package/src/components/shared/wave-physics-loader.tsx +155 -0
  12. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  13. package/src/components/story/constants.ts +114 -51
  14. package/src/components/story/detail/story-detail-body.tsx +13 -3
  15. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  16. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  17. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  18. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  19. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  20. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  21. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  22. package/src/components/story/peek-panel.tsx +8 -1
  23. package/src/components/story/relations/story-ref-row.tsx +319 -0
  24. package/src/components/story/story-list-view.tsx +3 -1
  25. package/src/components/story/story-panel-header.tsx +4 -1
  26. package/src/components/story/types.ts +17 -18
  27. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  28. package/src/components/story/views/story-list/index.tsx +16 -5
  29. package/src/components/story/views/story-list/row.tsx +5 -4
  30. package/src/components/ui/separator.tsx +1 -1
  31. package/src/hooks/use-notifications.ts +4 -1
  32. package/src/lib/__tests__/datetime.test.ts +16 -1
  33. package/src/lib/__tests__/rag.test.ts +37 -2
  34. package/src/lib/changelog/sdk-versions.ts +30 -30
  35. package/src/lib/commands/registry.ts +1 -1
  36. package/src/lib/commands/types.ts +3 -0
  37. package/src/lib/datetime.ts +1 -0
  38. package/src/lib/rag.ts +9 -2
  39. package/src/routeTree.gen.ts +23 -0
  40. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  41. package/src/routes/_nav/notifications/route.tsx +30 -0
  42. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  43. package/src/routes/api/notifications/stream.ts +4 -0
  44. package/src/server/ai-ports.ts +1 -1
  45. package/src/server/knowledge-public/github-sync.ts +1 -1
  46. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  47. package/src/server/notifications/notification-service.ts +137 -27
  48. package/src/server/serverFns/__tests__/knowledge-index.test.ts +178 -0
  49. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  50. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  51. package/src/server/serverFns/knowledge-index-utils.ts +104 -0
  52. package/src/server/serverFns/knowledge-index.ts +6 -67
  53. package/src/server/serverFns/knowledge.ts +8 -2
  54. package/src/server/serverFns/locale.ts +15 -3
  55. package/src/server/serverFns/notifications.ts +18 -4
  56. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  57. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  58. package/src/server/serverFns/story/index.ts +1 -2
  59. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  60. package/src/server/serverFns/story/story-groups.ts +23 -3
  61. package/src/server/serverFns/story/story-transfer.ts +5 -3
  62. package/src/server/serverFns/team.ts +51 -2
  63. package/src/components/knowledge/knowledge-settings-panel.tsx +0 -148
  64. package/src/components/notifications/notification-list.tsx +0 -232
  65. package/src/components/notifications/notification-preferences.tsx +0 -156
  66. package/src/components/story/relation-badge.tsx +0 -57
  67. package/src/components/story/relations/dependency-panel.tsx +0 -190
  68. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  69. package/src/components/story/relations/tab.tsx +0 -212
  70. package/src/config/sidebar-notifications.ts +0 -50
  71. package/src/lib/openai-client.ts +0 -62
  72. package/src/plugins/.gen-manifest.json +0 -13
  73. package/src/plugins/modules.gen.ts +0 -20
  74. package/src/plugins/plugins.gen.ts +0 -20
  75. package/src/plugins/ui.gen.ts +0 -8
  76. package/src/routes/_nav/inspiration/route.tsx +0 -8
  77. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  78. package/src/routes/_nav/prd/index.tsx +0 -9
  79. package/src/routes/_nav/prd/route.tsx +0 -8
  80. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  81. package/src/routes/_nav/tags/index.tsx +0 -8
  82. package/src/routes/_nav/tags/route.tsx +0 -8
  83. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  84. package/src/routes/share/prd/$prdId/route.tsx +0 -10
@@ -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
  })
@@ -0,0 +1,104 @@
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 { prisma } from "@allbluecn/database"
19
+ import { generateEmbedding, chunkText, resolveDefaultProviderConfig } from "@allbluecn/ai-gateway"
20
+ import { insertEmbedding, deleteEmbeddings } from "@/lib/pgvector"
21
+
22
+ export const KB_EMBEDDINGS_TABLE = "knowledge_embeddings"
23
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
24
+ const PLACEHOLDER_KEYS = new Set(["sk-your-api-key-here", "", "sk-or-your-api-key-here"])
25
+
26
+ // embeddings 走 OpenAI 兼容的 /embeddings 端点,仅这两个 adapterKind 可用;
27
+ // 其余(anthropic/gemini/groq/grok/ollama)无兼容路径,回落到 env 兜底。
28
+ const EMBEDDING_CAPABLE_ADAPTERS = new Set(["openai", "openrouter"])
29
+
30
+ export function extractText(content: unknown): string {
31
+ if (!content || typeof content !== "object") return ""
32
+ const obj = content as Record<string, unknown>
33
+ if (typeof obj.markdown === "string" && obj.markdown) return obj.markdown
34
+ if (typeof obj.html === "string" && obj.html) return obj.html.replace(/<[^>]*>/g, "")
35
+ return ""
36
+ }
37
+
38
+ export async function resolveAiConfig(kbId: string): Promise<{ apiKey?: string; baseUrl?: string }> {
39
+ const kb = await prisma.knowledgeBase.findUnique({
40
+ where: { id: kbId },
41
+ select: { userId: true },
42
+ })
43
+ if (!kb) return {}
44
+
45
+ // 新表优先:用户在 /settings/ai 配置的 key(AiProviderConfig.apiKey 加密存储,
46
+ // resolveDefaultProviderConfig 内部已解密)。旧实现只读 User.aiApiKey,
47
+ // 只配了新表的用户重索引必然报 "not configured"。
48
+ const cfg = await resolveDefaultProviderConfig(kb.userId)
49
+ if (cfg && EMBEDDING_CAPABLE_ADAPTERS.has(cfg.adapterKind)) {
50
+ const provider = await prisma.aiProvider.findUnique({
51
+ where: { providerId: cfg.providerId },
52
+ select: { defaultBaseUrl: true },
53
+ })
54
+ const baseUrl = cfg.baseUrl || provider?.defaultBaseUrl
55
+ // resolveDefaultProviderConfig 已跳过空 apiKey,此处 ?? undefined 仅作类型窄化
56
+ return { apiKey: cfg.apiKey ?? undefined, baseUrl: baseUrl ?? undefined }
57
+ }
58
+
59
+ // 旧字段兜底(迁移期)
60
+ const user = await prisma.user.findUnique({
61
+ where: { id: kb.userId },
62
+ select: { aiApiKey: true, aiProvider: true },
63
+ })
64
+ if (!user || !user.aiApiKey || PLACEHOLDER_KEYS.has(user.aiApiKey)) return {}
65
+
66
+ const result: { apiKey: string; baseUrl?: string } = { apiKey: user.aiApiKey }
67
+ if (user.aiProvider === "openrouter") {
68
+ result.baseUrl = OPENROUTER_BASE_URL
69
+ }
70
+ return result
71
+ }
72
+
73
+ export async function indexDocumentEmbeddings(
74
+ doc: { id: string; kbId: string; title: string; content: unknown },
75
+ aiOpts?: { apiKey?: string; baseUrl?: string }
76
+ ): Promise<number> {
77
+ const text = extractText(doc.content) || doc.title
78
+ if (!text) return 0
79
+
80
+ const chunks = chunkText(text)
81
+ await deleteEmbeddings(KB_EMBEDDINGS_TABLE, "documentId", doc.id)
82
+
83
+ for (let i = 0; i < chunks.length; i++) {
84
+ const embedding = await generateEmbedding(chunks[i], aiOpts)
85
+ await insertEmbedding(KB_EMBEDDINGS_TABLE, `${doc.id}-${i}`, chunks[i], embedding, {
86
+ kbId: doc.kbId,
87
+ documentId: doc.id,
88
+ chunkIndex: i,
89
+ })
90
+ }
91
+
92
+ return chunks.length
93
+ }
94
+
95
+ export async function indexDocumentById(documentId: string): Promise<number> {
96
+ const doc = await prisma.knowledgeDocument.findUnique({
97
+ where: { id: documentId },
98
+ select: { id: true, kbId: true, title: true, content: true },
99
+ })
100
+ if (!doc) return 0
101
+
102
+ const aiConfig = await resolveAiConfig(doc.kbId)
103
+ return indexDocumentEmbeddings(doc, aiConfig)
104
+ }
@@ -20,73 +20,12 @@ import { getRequest } from "@tanstack/react-start/server"
20
20
  import { z } from "zod"
21
21
  import { prisma } from "@allbluecn/database"
22
22
  import { requireAuth } from "@/server/auth-helpers"
23
- import { generateEmbedding, chunkText } from "@allbluecn/ai-gateway"
24
- import { insertEmbedding, deleteEmbeddings } from "@/lib/pgvector"
25
-
26
- const KB_EMBEDDINGS_TABLE = "knowledge_embeddings"
27
- const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
28
- const PLACEHOLDER_KEYS = new Set(["sk-your-api-key-here", "", "sk-or-your-api-key-here"])
29
-
30
- export function extractText(content: unknown): string {
31
- if (!content || typeof content !== "object") return ""
32
- const obj = content as Record<string, unknown>
33
- if (typeof obj.markdown === "string" && obj.markdown) return obj.markdown
34
- if (typeof obj.html === "string" && obj.html) return obj.html.replace(/<[^>]*>/g, "")
35
- return ""
36
- }
37
-
38
- export async function resolveAiConfig(kbId: string): Promise<{ apiKey?: string; baseUrl?: string }> {
39
- const kb = await prisma.knowledgeBase.findUnique({
40
- where: { id: kbId },
41
- select: { userId: true },
42
- })
43
- if (!kb) return {}
44
-
45
- const user = await prisma.user.findUnique({
46
- where: { id: kb.userId },
47
- select: { aiApiKey: true, aiProvider: true },
48
- })
49
- if (!user || !user.aiApiKey || PLACEHOLDER_KEYS.has(user.aiApiKey)) return {}
50
-
51
- const result: { apiKey: string; baseUrl?: string } = { apiKey: user.aiApiKey }
52
- if (user.aiProvider === "openrouter") {
53
- result.baseUrl = OPENROUTER_BASE_URL
54
- }
55
- return result
56
- }
57
-
58
- export async function indexDocumentEmbeddings(
59
- doc: { id: string; kbId: string; title: string; content: unknown },
60
- aiOpts?: { apiKey?: string; baseUrl?: string }
61
- ): Promise<number> {
62
- const text = extractText(doc.content) || doc.title
63
- if (!text) return 0
64
-
65
- const chunks = chunkText(text)
66
- await deleteEmbeddings(KB_EMBEDDINGS_TABLE, "documentId", doc.id)
67
-
68
- for (let i = 0; i < chunks.length; i++) {
69
- const embedding = await generateEmbedding(chunks[i], aiOpts)
70
- await insertEmbedding(KB_EMBEDDINGS_TABLE, `${doc.id}-${i}`, chunks[i], embedding, {
71
- kbId: doc.kbId,
72
- documentId: doc.id,
73
- chunkIndex: i,
74
- })
75
- }
76
-
77
- return chunks.length
78
- }
79
-
80
- export async function indexDocumentById(documentId: string): Promise<number> {
81
- const doc = await prisma.knowledgeDocument.findUnique({
82
- where: { id: documentId },
83
- select: { id: true, kbId: true, title: true, content: true },
84
- })
85
- if (!doc) return 0
86
-
87
- const aiConfig = await resolveAiConfig(doc.kbId)
88
- return indexDocumentEmbeddings(doc, aiConfig)
89
- }
23
+ import { deleteEmbeddings } from "@/lib/pgvector"
24
+ import {
25
+ KB_EMBEDDINGS_TABLE,
26
+ indexDocumentEmbeddings,
27
+ resolveAiConfig,
28
+ } from "./knowledge-index-utils"
90
29
 
91
30
  export const indexKnowledgeDocumentFn = createServerFn({ method: "POST" })
92
31
  .validator(z.object({ documentId: z.string() }))
@@ -20,7 +20,7 @@ import { getRequest } from '@tanstack/react-start/server'
20
20
  import { z } from 'zod'
21
21
  import { prisma } from '@allbluecn/database'
22
22
  import { requireAuth } from '@/server/auth-helpers'
23
- import { indexDocumentById } from '@/server/serverFns/knowledge-index'
23
+ import { indexDocumentById } from '@/server/serverFns/knowledge-index-utils'
24
24
  import { deleteEmbeddings } from '@/lib/pgvector'
25
25
  import { requirePermission } from '@/server/permissions'
26
26
  import { auditLog } from '@/server/audit'
@@ -306,7 +306,13 @@ export const updateDocumentFn = createServerFn({ method: 'POST' })
306
306
  })
307
307
 
308
308
  if (data.content !== undefined) {
309
- indexDocumentById(data.id).catch(() => {})
309
+ // fire-and-forget:索引失败不阻塞文档保存,但不能静默——
310
+ // 否则无 AI key / 端点错误时知识库检索悄悄失效且无从排查。
311
+ indexDocumentById(data.id).catch((e) => {
312
+ console.warn(
313
+ `知识库向量索引失败 documentId=${data.id}: ${e instanceof Error ? e.message : String(e)}`,
314
+ )
315
+ })
310
316
  }
311
317
 
312
318
  await auditLog({ workspaceId: '', userId, resourceType: 'document', resourceId: data.id, action: 'document.update' })
@@ -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 },
@@ -0,0 +1,239 @@
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", () => ({
44
+ getRequest: () => ({}),
45
+ }));
46
+
47
+ const mocks = vi.hoisted(() => ({
48
+ depFindMany: vi.fn(),
49
+ depFindFirst: vi.fn(),
50
+ depCreate: vi.fn(),
51
+ depDelete: vi.fn(),
52
+ storyFindMany: vi.fn(),
53
+ currentSession: vi.fn(),
54
+ }));
55
+
56
+ vi.mock("@allbluecn/database", () => {
57
+ const prisma = {
58
+ storyDependency: {
59
+ findMany: mocks.depFindMany,
60
+ findFirst: mocks.depFindFirst,
61
+ create: mocks.depCreate,
62
+ delete: mocks.depDelete,
63
+ },
64
+ story: { findMany: mocks.storyFindMany },
65
+ };
66
+ return { prisma, Prisma: {} };
67
+ });
68
+
69
+ vi.mock("@/server/auth-helpers", () => ({
70
+ requireAuth: mocks.currentSession,
71
+ }));
72
+
73
+ import {
74
+ addDependenciesFn,
75
+ removeDependencyFn,
76
+ getDependenciesFn,
77
+ } from "../story-dependencies";
78
+
79
+ const STORY_ROW = (id: string) => ({
80
+ id,
81
+ identifier: `PROJ-${id}`,
82
+ sequenceId: 1,
83
+ name: `story ${id}`,
84
+ description: null,
85
+ state: "backlog",
86
+ priority: "medium",
87
+ archived: false,
88
+ startDate: null,
89
+ targetDate: null,
90
+ boardOrder: 0,
91
+ projectId: "p1",
92
+ parentId: null,
93
+ parent: null,
94
+ assignee: null,
95
+ createdBy: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
96
+ updatedBy: null,
97
+ labels: [],
98
+ createdAt: new Date("2026-01-01T00:00:00Z"),
99
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
100
+ });
101
+
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
105
+ // 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
106
+ mocks.depFindMany.mockResolvedValue([]);
107
+ });
108
+
109
+ describe("addDependenciesFn", () => {
110
+ it("自依赖拒绝并计入 failed", async () => {
111
+ const res = await addDependenciesFn({
112
+ data: { items: [{ sourceId: "a", targetId: "a", type: "BLOCKS" }] },
113
+ });
114
+ expect(res.created).toHaveLength(0);
115
+ expect(res.failed[0].targetId).toBe("a");
116
+ expect(mocks.depCreate).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("pair 级判重:同 pair 任意方向任意类型已存在则拒绝", async () => {
120
+ mocks.depFindFirst.mockResolvedValue({ id: "d0" }); // 命中已有边
121
+ const res = await addDependenciesFn({
122
+ data: {
123
+ items: [
124
+ { sourceId: "a", targetId: "b", type: "STARTS_BEFORE" }, // 正向查 (a,b,*)
125
+ ],
126
+ },
127
+ });
128
+ expect(res.failed[0].targetId).toBe("b");
129
+ expect(mocks.depCreate).not.toHaveBeenCalled();
130
+ // 守住双向查询:实现退化为单方向(漏掉反向 OR)时本断言必须失败
131
+ expect(mocks.depFindFirst).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ where: expect.objectContaining({
134
+ OR: expect.arrayContaining([
135
+ { sourceId: "a", targetId: "b" },
136
+ { sourceId: "b", targetId: "a" },
137
+ ]),
138
+ }),
139
+ }),
140
+ );
141
+ });
142
+
143
+ it("循环依赖拒绝:target 可达 source(跨类型混合环)", async () => {
144
+ mocks.depFindFirst.mockResolvedValue(null); // 无重复
145
+ // 图:b -> a 已存在(b BLOCKS a);插入 a -> b 成环
146
+ // handler 开头一次性取全边,返回全图唯一一条边
147
+ mocks.depFindMany.mockResolvedValue([{ sourceId: "b", targetId: "a" }]);
148
+ const res = await addDependenciesFn({
149
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
150
+ });
151
+ expect(res.failed[0].targetId).toBe("b");
152
+ expect(mocks.depCreate).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("批内互指环:第二条被逐条拦截", async () => {
156
+ mocks.depFindFirst.mockResolvedValue(null);
157
+ mocks.depFindMany.mockResolvedValue([]);
158
+ mocks.depCreate.mockImplementation(({ data }: any) =>
159
+ Promise.resolve({ id: `d-${data.sourceId}-${data.targetId}`, ...data }),
160
+ );
161
+ // 第一条插入 a->b 后,第二条 b->a 的环检测需看到它:
162
+ // 实现必须把本批已插入边并入检测图(depFindMany 之后的内存集合)
163
+ // 这里 mock depFindMany 每次返回空(DB 无边),实现需自维护本批边
164
+ const res = await addDependenciesFn({
165
+ data: {
166
+ items: [
167
+ { sourceId: "a", targetId: "b", type: "BLOCKS" },
168
+ { sourceId: "b", targetId: "a", type: "STARTS_BEFORE" },
169
+ ],
170
+ },
171
+ });
172
+ expect(res.created).toHaveLength(1);
173
+ expect(res.failed[0].targetId).toBe("a");
174
+ });
175
+
176
+ it("合法边创建成功", async () => {
177
+ mocks.depFindFirst.mockResolvedValue(null);
178
+ mocks.depFindMany.mockResolvedValue([]);
179
+ mocks.depCreate.mockResolvedValue({ id: "d1" });
180
+ const res = await addDependenciesFn({
181
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
182
+ });
183
+ expect(res.created).toHaveLength(1);
184
+ expect(mocks.depCreate).toHaveBeenCalledOnce();
185
+ // 性能契约:环检测的图只查一次(取全边),且不带任何 where 过滤
186
+ expect(mocks.depFindMany).toHaveBeenCalledTimes(1);
187
+ expect(mocks.depFindMany).toHaveBeenCalledWith({
188
+ select: { sourceId: true, targetId: true },
189
+ });
190
+ });
191
+
192
+ it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
193
+ await expect(
194
+ addDependenciesFn({
195
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKED_BY" as any }] },
196
+ }),
197
+ ).rejects.toThrow();
198
+ expect(mocks.depCreate).not.toHaveBeenCalled();
199
+ });
200
+ });
201
+
202
+ describe("removeDependencyFn", () => {
203
+ it("按 depId 删除", async () => {
204
+ mocks.depDelete.mockResolvedValue({ id: "d1" });
205
+ const res = await removeDependencyFn({ data: { depId: "d1" } });
206
+ expect(res.id).toBe("d1");
207
+ expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
208
+ });
209
+ });
210
+
211
+ describe("getDependenciesFn", () => {
212
+ it("双视角分组:dependsOn 取 source,dependents 取 target", async () => {
213
+ mocks.depFindMany.mockResolvedValue([
214
+ { id: "d1", sourceId: "s1", targetId: "me", type: "BLOCKS" },
215
+ { id: "d2", sourceId: "me", targetId: "t1", type: "STARTS_BEFORE" },
216
+ ]);
217
+ mocks.storyFindMany.mockImplementation(({ where }: any) => {
218
+ const rows = [STORY_ROW("s1"), STORY_ROW("t1"), STORY_ROW("me")];
219
+ return Promise.resolve(rows.filter((r) => where.id.in.includes(r.id)));
220
+ });
221
+ const res = await getDependenciesFn({ data: { storyId: "me" } });
222
+ expect(res.dependsOn).toHaveLength(1);
223
+ expect(res.dependsOn[0].id).toBe("s1");
224
+ expect(res.dependsOn[0].depId).toBe("d1");
225
+ expect(res.dependsOn[0].depType).toBe("BLOCKS");
226
+ expect(res.dependents).toHaveLength(1);
227
+ expect(res.dependents[0].id).toBe("t1");
228
+ expect(res.dependents[0].depType).toBe("STARTS_BEFORE");
229
+ // 日期已 ISO 化(castStoryUsers)
230
+ expect(res.dependsOn[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
231
+ });
232
+
233
+ it("无边时返回空分组且不查需求表", async () => {
234
+ mocks.depFindMany.mockResolvedValue([]);
235
+ const res = await getDependenciesFn({ data: { storyId: "me" } });
236
+ expect(res).toEqual({ dependsOn: [], dependents: [] });
237
+ expect(mocks.storyFindMany).not.toHaveBeenCalled();
238
+ });
239
+ });