@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
@@ -573,3 +573,46 @@ export const ensureBuiltinProjectFn = createServerFn({ method: "GET" }).handler(
573
573
  const projectId = await ensureBuiltinIndependentProject(userId);
574
574
  return { id: projectId, name: BUILTIN_INDEPENDENT_NAME, displayId: BUILTIN_INDEPENDENT_DISPLAY_ID, icon: BUILTIN_INDEPENDENT_ICON };
575
575
  });
576
+
577
+ export const listProjectKbsFn = createServerFn({ method: "GET" })
578
+ .validator(z.object({ projectId: z.string() }))
579
+ .handler(async ({ data }) => {
580
+ const request = getRequest();
581
+ await requireAuth(request);
582
+ const rows = await prisma.projectKnowledgeBase.findMany({
583
+ where: { projectId: data.projectId },
584
+ select: {
585
+ kb: { select: { id: true, name: true, icon: true, visibility: true } },
586
+ },
587
+ orderBy: { createdAt: "asc" },
588
+ });
589
+ return rows.map((r) => r.kb);
590
+ });
591
+
592
+ export const setProjectKbsFn = createServerFn({ method: "POST" })
593
+ .validator(z.object({ projectId: z.string(), kbIds: z.array(z.string()) }))
594
+ .handler(async ({ data }) => {
595
+ const request = getRequest();
596
+ await requireAuth(request);
597
+ if (data.kbIds.length > 0) {
598
+ const valid = await prisma.knowledgeBase.findMany({
599
+ where: { id: { in: data.kbIds }, visibility: "team", isDeleted: false },
600
+ select: { id: true },
601
+ });
602
+ if (valid.length !== data.kbIds.length) {
603
+ throw new Error("仅允许团队可见的知识库关联项目");
604
+ }
605
+ }
606
+ await prisma.$transaction(async (tx) => {
607
+ await tx.projectKnowledgeBase.deleteMany({
608
+ where: { projectId: data.projectId, kbId: { notIn: data.kbIds } },
609
+ });
610
+ if (data.kbIds.length > 0) {
611
+ await tx.projectKnowledgeBase.createMany({
612
+ data: data.kbIds.map((kbId) => ({ projectId: data.projectId, kbId })),
613
+ skipDuplicates: true,
614
+ });
615
+ }
616
+ });
617
+ return { ok: true };
618
+ });
@@ -0,0 +1,56 @@
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 } from "vitest";
19
+ import { isIndependentProject, buildKbScopeWhere } from "../kb-scope";
20
+
21
+ describe("kb-scope 可用范围判定", () => {
22
+ it("独立需求项目(displayId=STORY)识别", () => {
23
+ expect(isIndependentProject("STORY")).toBe(true);
24
+ expect(isIndependentProject("story")).toBe(false);
25
+ expect(isIndependentProject(null)).toBe(false);
26
+ expect(isIndependentProject("PROJ")).toBe(false);
27
+ });
28
+
29
+ it("普通项目:general ∪ 项目关联(不含他人 private/team)", () => {
30
+ const where = buildKbScopeWhere({
31
+ projectId: "p1",
32
+ projectDisplayId: "PROJ",
33
+ userId: "u1",
34
+ });
35
+ expect(where).toEqual({
36
+ OR: [
37
+ { visibility: "general" },
38
+ { projectLinks: { some: { projectId: "p1" } } },
39
+ ],
40
+ });
41
+ });
42
+
43
+ it("独立需求项目:自己的全部 ∪ 他人 team/general", () => {
44
+ const where = buildKbScopeWhere({
45
+ projectId: "p2",
46
+ projectDisplayId: "STORY",
47
+ userId: "u1",
48
+ });
49
+ expect(where).toEqual({
50
+ OR: [
51
+ { userId: "u1" },
52
+ { visibility: { in: ["team", "general"] } },
53
+ ],
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,222 @@
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
+ storyFindUnique: vi.fn(),
49
+ kbFindMany: vi.fn(),
50
+ kbFindFirst: vi.fn(),
51
+ docFindMany: vi.fn(),
52
+ linkFindMany: vi.fn(),
53
+ linkCreateMany: vi.fn(),
54
+ extLinkFindMany: vi.fn(),
55
+ extLinkCreateMany: vi.fn(),
56
+ extLinkDeleteMany: vi.fn(),
57
+ activityCreate: vi.fn(),
58
+ currentSession: vi.fn(),
59
+ }));
60
+
61
+ vi.mock("@allbluecn/database", () => {
62
+ const prisma = {
63
+ story: { findUnique: mocks.storyFindUnique },
64
+ knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
65
+ knowledgeDocument: { findMany: mocks.docFindMany },
66
+ storyDocLink: { findMany: mocks.linkFindMany, createMany: mocks.linkCreateMany },
67
+ storyExternalLink: {
68
+ findMany: mocks.extLinkFindMany,
69
+ createMany: mocks.extLinkCreateMany,
70
+ deleteMany: mocks.extLinkDeleteMany,
71
+ },
72
+ storyActivity: { create: mocks.activityCreate },
73
+ };
74
+ return { prisma, Prisma: {} };
75
+ });
76
+
77
+ vi.mock("@/server/auth-helpers", () => ({
78
+ requireAuth: mocks.currentSession,
79
+ }));
80
+
81
+ import {
82
+ listStoryAvailableKbsFn,
83
+ searchKbDocsFn,
84
+ linkDocsFn,
85
+ getLinkedDocsFn,
86
+ } from "../story-assoc";
87
+
88
+ const SESSION = { user: { id: "u1" } };
89
+ const STORY_WITH_PROJECT = {
90
+ project: { id: "p1", displayId: "PROJ" },
91
+ };
92
+ const STORY_INDEPENDENT = {
93
+ project: { id: "p2", displayId: "STORY" },
94
+ };
95
+
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ mocks.currentSession.mockResolvedValue(SESSION);
99
+ mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
100
+ mocks.extLinkFindMany.mockResolvedValue([]);
101
+ mocks.extLinkCreateMany.mockResolvedValue({ count: 0 });
102
+ mocks.extLinkDeleteMany.mockResolvedValue({ count: 0 });
103
+ mocks.activityCreate.mockResolvedValue({
104
+ id: "act1",
105
+ action: "kb_linked",
106
+ field: null,
107
+ oldValue: null,
108
+ newValue: "1",
109
+ isAI: false,
110
+ createdAt: new Date("2026-08-31T00:00:00Z"),
111
+ user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
112
+ });
113
+ });
114
+
115
+ describe("listStoryAvailableKbsFn", () => {
116
+ it("普通项目:按 scope 查询返回 KB 列表", async () => {
117
+ mocks.kbFindMany.mockResolvedValue([
118
+ { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" },
119
+ { id: "kb2", name: "通用库", icon: "book", visibility: "general" },
120
+ ]);
121
+ const result = await listStoryAvailableKbsFn({ data: { storyId: "s1" } });
122
+ expect(result).toHaveLength(2);
123
+ expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
124
+ const where = mocks.kbFindMany.mock.calls[0][0].where;
125
+ expect(where.AND).toBeDefined(); // AND: [scope, { isDeleted: false }]
126
+ });
127
+
128
+ it("需求不存在抛错", async () => {
129
+ mocks.storyFindUnique.mockResolvedValue(null);
130
+ await expect(listStoryAvailableKbsFn({ data: { storyId: "nope" } })).rejects.toMatchObject({
131
+ message: expect.stringContaining("需求不存在"),
132
+ });
133
+ });
134
+ });
135
+
136
+ describe("searchKbDocsFn 两态", () => {
137
+ it("指定 kbId:全量树查询(take 500,含 parentId/type/kb 元数据)", async () => {
138
+ mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
139
+ mocks.kbFindFirst.mockResolvedValue({ id: "kb1" }); // 范围内
140
+ mocks.docFindMany.mockResolvedValue([
141
+ { id: "d1", title: "页1", parentId: null, kb: { id: "kb1", name: "团队库", icon: "notebook" }, icon: "doc" },
142
+ ]);
143
+ const result = await searchKbDocsFn({ data: { storyId: "s1", kbId: "kb1", query: "" } });
144
+ expect(result.items[0]).toMatchObject({ id: "d1", kbName: "团队库" });
145
+ expect(result.nextCursor).toBeNull();
146
+ const args = mocks.docFindMany.mock.calls[0][0];
147
+ expect(args.take).toBe(500);
148
+ expect(args.where.kbId).toBe("kb1");
149
+ });
150
+
151
+ it("指定范围外 kbId:抛越界错误", async () => {
152
+ mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
153
+ mocks.kbFindFirst.mockResolvedValue(null); // 不在 scope 内
154
+ await expect(
155
+ searchKbDocsFn({ data: { storyId: "s1", kbId: "kbX", query: "" } }),
156
+ ).rejects.toMatchObject({ message: expect.stringContaining("不在可用范围") });
157
+ });
158
+
159
+ it("全部模式:平铺分页(take 21 判 hasMore)", async () => {
160
+ mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
161
+ mocks.docFindMany.mockResolvedValue([]);
162
+ await searchKbDocsFn({ data: { storyId: "s1", query: "关键词" } });
163
+ const args = mocks.docFindMany.mock.calls[0][0];
164
+ expect(args.take).toBe(21);
165
+ expect(args.where.kb).toBeDefined(); // scope 下推到 kb 关系
166
+ });
167
+ });
168
+
169
+ describe("linkDocsFn 批量关联", () => {
170
+ it("合法项批量建链、已存在跳过、越界归 failed", async () => {
171
+ mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
172
+ mocks.docFindMany.mockResolvedValue([
173
+ { id: "d1", kbId: "kb1" }, // 合法
174
+ { id: "d2", kbId: "kb9" }, // 越界(kb9 不在 scope)
175
+ ]);
176
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]); // scope 内 KB 集合
177
+ mocks.linkFindMany.mockResolvedValue([{ docId: "d1" }]); // d1 已关联
178
+ mocks.linkCreateMany.mockResolvedValue({ count: 0 });
179
+
180
+ const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d1", "d2"] } });
181
+ expect(result.linked).toEqual([]);
182
+ expect(result.skipped).toEqual(["d1"]);
183
+ expect(result.failed).toEqual([{ docId: "d2", reason: "outOfScope" }]);
184
+ expect(mocks.linkCreateMany).not.toHaveBeenCalled(); // 无新增
185
+ });
186
+
187
+ it("新增项走 createMany skipDuplicates", async () => {
188
+ mocks.docFindMany.mockResolvedValue([{ id: "d3", kbId: "kb1" }]);
189
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]);
190
+ mocks.linkFindMany.mockResolvedValue([]);
191
+ mocks.linkCreateMany.mockResolvedValue({ count: 1 });
192
+ const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d3"] } });
193
+ expect(result.linked).toEqual(["d3"]);
194
+ expect(mocks.linkCreateMany.mock.calls[0][0].data).toEqual([{ storyId: "s1", docId: "d3" }]);
195
+ });
196
+ });
197
+
198
+ describe("getLinkedDocsFn 扩展返回 KB 元数据", () => {
199
+ it("返回对齐 DocBrief 的字段", async () => {
200
+ mocks.linkFindMany.mockResolvedValue([
201
+ {
202
+ doc: {
203
+ id: "d1",
204
+ title: "页1",
205
+ kb: { id: "kb1", name: "团队库", icon: "notebook" },
206
+ icon: "doc",
207
+ parentId: null,
208
+ },
209
+ },
210
+ ]);
211
+ const result = await getLinkedDocsFn({ data: { storyId: "s1" } });
212
+ expect(result[0]).toEqual({
213
+ id: "d1",
214
+ title: "页1",
215
+ kbId: "kb1",
216
+ kbName: "团队库",
217
+ kbIcon: "notebook",
218
+ docIcon: "doc",
219
+ parentId: null,
220
+ });
221
+ });
222
+ });
@@ -0,0 +1,265 @@
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
+ activityCreate: vi.fn(),
54
+ currentSession: vi.fn(),
55
+ }));
56
+
57
+ vi.mock("@allbluecn/database", () => {
58
+ const prisma = {
59
+ storyDependency: {
60
+ findMany: mocks.depFindMany,
61
+ findFirst: mocks.depFindFirst,
62
+ create: mocks.depCreate,
63
+ delete: mocks.depDelete,
64
+ },
65
+ story: { findMany: mocks.storyFindMany },
66
+ storyActivity: { create: mocks.activityCreate },
67
+ };
68
+ return { prisma, Prisma: {} };
69
+ });
70
+
71
+ vi.mock("@/server/auth-helpers", () => ({
72
+ requireAuth: mocks.currentSession,
73
+ }));
74
+
75
+ import {
76
+ addDependenciesFn,
77
+ removeDependencyFn,
78
+ getDependenciesFn,
79
+ } from "../story-dependencies";
80
+
81
+ const STORY_ROW = (id: string) => ({
82
+ id,
83
+ identifier: `PROJ-${id}`,
84
+ sequenceId: 1,
85
+ name: `story ${id}`,
86
+ description: null,
87
+ state: "backlog",
88
+ priority: "medium",
89
+ archived: false,
90
+ startDate: null,
91
+ targetDate: null,
92
+ boardOrder: 0,
93
+ projectId: "p1",
94
+ parentId: null,
95
+ parent: null,
96
+ assignee: null,
97
+ createdBy: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
98
+ updatedBy: null,
99
+ labels: [],
100
+ createdAt: new Date("2026-01-01T00:00:00Z"),
101
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
102
+ });
103
+
104
+ beforeEach(() => {
105
+ vi.clearAllMocks();
106
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
107
+ // 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
108
+ mocks.depFindMany.mockResolvedValue([]);
109
+ // 活动写入段:端点 label 查询 + create 返回(含 select 字段)
110
+ mocks.storyFindMany.mockResolvedValue([
111
+ { id: "a", identifier: "A-1", name: "story a" },
112
+ { id: "b", identifier: "B-1", name: "story b" },
113
+ ]);
114
+ mocks.activityCreate.mockImplementation(({ data }: any) =>
115
+ Promise.resolve({
116
+ id: `act-${data.storyId}`,
117
+ action: "dependency_added",
118
+ field: null,
119
+ oldValue: null,
120
+ newValue: data.newValue,
121
+ isAI: false,
122
+ createdAt: new Date("2026-08-31T00:00:00Z"),
123
+ storyId: data.storyId,
124
+ user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
125
+ }),
126
+ );
127
+ });
128
+
129
+ describe("addDependenciesFn", () => {
130
+ it("自依赖拒绝并计入 failed", async () => {
131
+ const res = await addDependenciesFn({
132
+ data: { items: [{ sourceId: "a", targetId: "a", type: "BLOCKS" }] },
133
+ });
134
+ expect(res.created).toHaveLength(0);
135
+ expect(res.failed[0].targetId).toBe("a");
136
+ expect(mocks.depCreate).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("pair 级判重:同 pair 任意方向任意类型已存在则拒绝", async () => {
140
+ mocks.depFindFirst.mockResolvedValue({ id: "d0" }); // 命中已有边
141
+ const res = await addDependenciesFn({
142
+ data: {
143
+ items: [
144
+ { sourceId: "a", targetId: "b", type: "STARTS_BEFORE" }, // 正向查 (a,b,*)
145
+ ],
146
+ },
147
+ });
148
+ expect(res.failed[0].targetId).toBe("b");
149
+ expect(mocks.depCreate).not.toHaveBeenCalled();
150
+ // 守住双向查询:实现退化为单方向(漏掉反向 OR)时本断言必须失败
151
+ expect(mocks.depFindFirst).toHaveBeenCalledWith(
152
+ expect.objectContaining({
153
+ where: expect.objectContaining({
154
+ OR: expect.arrayContaining([
155
+ { sourceId: "a", targetId: "b" },
156
+ { sourceId: "b", targetId: "a" },
157
+ ]),
158
+ }),
159
+ }),
160
+ );
161
+ });
162
+
163
+ it("循环依赖拒绝:target 可达 source(跨类型混合环)", async () => {
164
+ mocks.depFindFirst.mockResolvedValue(null); // 无重复
165
+ // 图:b -> a 已存在(b BLOCKS a);插入 a -> b 成环
166
+ // handler 开头一次性取全边,返回全图唯一一条边
167
+ mocks.depFindMany.mockResolvedValue([{ sourceId: "b", targetId: "a" }]);
168
+ const res = await addDependenciesFn({
169
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
170
+ });
171
+ expect(res.failed[0].targetId).toBe("b");
172
+ expect(mocks.depCreate).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("批内互指环:第二条被逐条拦截", async () => {
176
+ mocks.depFindFirst.mockResolvedValue(null);
177
+ mocks.depFindMany.mockResolvedValue([]);
178
+ mocks.depCreate.mockImplementation(({ data }: any) =>
179
+ Promise.resolve({ id: `d-${data.sourceId}-${data.targetId}`, ...data }),
180
+ );
181
+ // 第一条插入 a->b 后,第二条 b->a 的环检测需看到它:
182
+ // 实现必须把本批已插入边并入检测图(depFindMany 之后的内存集合)
183
+ // 这里 mock depFindMany 每次返回空(DB 无边),实现需自维护本批边
184
+ const res = await addDependenciesFn({
185
+ data: {
186
+ items: [
187
+ { sourceId: "a", targetId: "b", type: "BLOCKS" },
188
+ { sourceId: "b", targetId: "a", type: "STARTS_BEFORE" },
189
+ ],
190
+ },
191
+ });
192
+ expect(res.created).toHaveLength(1);
193
+ expect(res.failed[0].targetId).toBe("a");
194
+ });
195
+
196
+ it("合法边创建成功", async () => {
197
+ mocks.depFindFirst.mockResolvedValue(null);
198
+ mocks.depFindMany.mockResolvedValue([]);
199
+ mocks.depCreate.mockResolvedValue({ id: "d1" });
200
+ const res = await addDependenciesFn({
201
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
202
+ });
203
+ expect(res.created).toHaveLength(1);
204
+ expect(mocks.depCreate).toHaveBeenCalledOnce();
205
+ // 性能契约:环检测的图只查一次(取全边),且不带任何 where 过滤
206
+ expect(mocks.depFindMany).toHaveBeenCalledTimes(1);
207
+ expect(mocks.depFindMany).toHaveBeenCalledWith({
208
+ select: { sourceId: true, targetId: true },
209
+ });
210
+ // 活动契约:依赖两端各记一条「添加依赖关系」,newValue 为对方描述
211
+ expect(res.activities).toHaveLength(2);
212
+ expect(res.activities.map((x) => [x.storyId, x.newValue]).sort()).toEqual([
213
+ ["a", "B-1 story b"],
214
+ ["b", "A-1 story a"],
215
+ ]);
216
+ });
217
+
218
+ it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
219
+ await expect(
220
+ addDependenciesFn({
221
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKED_BY" as any }] },
222
+ }),
223
+ ).rejects.toThrow();
224
+ expect(mocks.depCreate).not.toHaveBeenCalled();
225
+ });
226
+ });
227
+
228
+ describe("removeDependencyFn", () => {
229
+ it("按 depId 删除", async () => {
230
+ mocks.depDelete.mockResolvedValue({ id: "d1" });
231
+ const res = await removeDependencyFn({ data: { depId: "d1" } });
232
+ expect(res.id).toBe("d1");
233
+ expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
234
+ });
235
+ });
236
+
237
+ describe("getDependenciesFn", () => {
238
+ it("双视角分组:dependsOn 取 source,dependents 取 target", async () => {
239
+ mocks.depFindMany.mockResolvedValue([
240
+ { id: "d1", sourceId: "s1", targetId: "me", type: "BLOCKS" },
241
+ { id: "d2", sourceId: "me", targetId: "t1", type: "STARTS_BEFORE" },
242
+ ]);
243
+ mocks.storyFindMany.mockImplementation(({ where }: any) => {
244
+ const rows = [STORY_ROW("s1"), STORY_ROW("t1"), STORY_ROW("me")];
245
+ return Promise.resolve(rows.filter((r) => where.id.in.includes(r.id)));
246
+ });
247
+ const res = await getDependenciesFn({ data: { storyId: "me" } });
248
+ expect(res.dependsOn).toHaveLength(1);
249
+ expect(res.dependsOn[0].id).toBe("s1");
250
+ expect(res.dependsOn[0].depId).toBe("d1");
251
+ expect(res.dependsOn[0].depType).toBe("BLOCKS");
252
+ expect(res.dependents).toHaveLength(1);
253
+ expect(res.dependents[0].id).toBe("t1");
254
+ expect(res.dependents[0].depType).toBe("STARTS_BEFORE");
255
+ // 日期已 ISO 化(castStoryUsers)
256
+ expect(res.dependsOn[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
257
+ });
258
+
259
+ it("无边时返回空分组且不查需求表", async () => {
260
+ mocks.depFindMany.mockResolvedValue([]);
261
+ const res = await getDependenciesFn({ data: { storyId: "me" } });
262
+ expect(res).toEqual({ dependsOn: [], dependents: [] });
263
+ expect(mocks.storyFindMany).not.toHaveBeenCalled();
264
+ });
265
+ });
@@ -84,7 +84,7 @@ describe("listStoriesInGroupFn", () => {
84
84
  const arg = mocks.findMany.mock.calls[0][0] as any;
85
85
  expect(arg.where.AND).toContainEqual({ state: "todo" });
86
86
  expect(arg.where.archived).toBe(false);
87
- expect(arg.orderBy).toEqual({ createdAt: "desc" });
87
+ expect(arg.orderBy).toEqual([{ createdAt: "desc" }, { id: "asc" }]);
88
88
  expect(mocks.count).not.toHaveBeenCalled();
89
89
  });
90
90
 
@@ -222,6 +222,24 @@ describe("listStoryGroupsFn", () => {
222
222
  expect(out.groups.find((g) => g.key === "backlog")?.total).toBe(0);
223
223
  });
224
224
 
225
+ it("state/priority 组间顺序按枚举序排列(与筛选 pop 一致),不受 DB 返回顺序影响", async () => {
226
+ mocks.groupBy.mockResolvedValue([
227
+ { state: "done", _count: { _all: 91 } },
228
+ { state: "in_progress", _count: { _all: 2 } },
229
+ { state: "todo", _count: { _all: 1 } },
230
+ ]);
231
+ const stateOut = await listStoryGroupsFn({ data: { archived: false, groupBy: "state" } });
232
+ expect(stateOut.groups.map((g) => g.key)).toEqual(["todo", "in_progress", "done"]);
233
+
234
+ mocks.groupBy.mockResolvedValue([
235
+ { priority: "low", _count: { _all: 4 } },
236
+ { priority: "urgent", _count: { _all: 2 } },
237
+ { priority: "medium", _count: { _all: 1 } },
238
+ ]);
239
+ const priorityOut = await listStoryGroupsFn({ data: { archived: false, groupBy: "priority" } });
240
+ expect(priorityOut.groups.map((g) => g.key)).toEqual(["urgent", "medium", "low"]);
241
+ });
242
+
225
243
  it("labels 分组按 storyLabel.groupBy 聚合并计入 no-labels", async () => {
226
244
  mocks.storyLabelGroupBy.mockResolvedValue([
227
245
  { labelId: "l1", _count: { _all: 2 } },
@@ -81,10 +81,9 @@ export {
81
81
  } from "./story-children";
82
82
 
83
83
  export {
84
- addDependencyFn,
84
+ addDependenciesFn,
85
85
  removeDependencyFn,
86
86
  getDependenciesFn,
87
- validateDependencyFn,
88
87
  } from "./story-dependencies";
89
88
 
90
89
  export {
@@ -94,11 +93,11 @@ export {
94
93
  getLinksFn,
95
94
  } from "./story-links";
96
95
 
96
+ export { checkLinkEmbeddableFn } from "./link-preview";
97
+
97
98
  export {
98
- uploadAttachmentFn,
99
99
  removeAttachmentFn,
100
100
  getAttachmentsFn,
101
- mockCloudStorageFn,
102
101
  } from "./story-attachments";
103
102
 
104
103
  export {
@@ -106,10 +105,12 @@ export {
106
105
  unlinkPrdFn,
107
106
  getLinkedPrdsFn,
108
107
  searchPrdsFn,
109
- linkDocFn,
108
+ listStoryAvailableKbsFn,
109
+ searchKbDocsFn,
110
+ linkDocsFn,
110
111
  unlinkDocFn,
111
112
  getLinkedDocsFn,
112
- searchDocsFn,
113
+ type SearchDocRow,
113
114
  } from "./story-assoc";
114
115
 
115
116
  export {