@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
@@ -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 {
@@ -20,199 +20,143 @@ 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 type { DependencyType } from "@/components/story/types";
23
+ import { STORY_LIST_SELECT, castStoryUsers } from "./types";
24
+ import type { DependencyRow } from "@/components/story/types";
24
25
 
25
- async function hasCycle(sourceId: string, targetId: string): Promise<boolean> {
26
- const visited = new Set<string>();
26
+ const edgeTypeSchema = z.enum(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]);
27
27
 
28
- async function dfs(currentId: string): Promise<boolean> {
29
- if (visited.has(currentId)) return false;
30
- visited.add(currentId);
31
-
32
- const outgoing = await prisma.storyDependency.findMany({
33
- where: { sourceId: currentId, type: "BLOCKS" },
34
- select: { targetId: true },
35
- });
36
-
37
- for (const dep of outgoing) {
38
- if (dep.targetId === targetId) return true;
39
- if (await dfs(dep.targetId)) return true;
40
- }
41
-
42
- return false;
43
- }
44
-
45
- return dfs(sourceId);
46
- }
47
-
48
- export const addDependencyFn = createServerFn({ method: "POST" })
28
+ export const addDependenciesFn = createServerFn({ method: "POST" })
49
29
  .validator(
50
30
  z.object({
51
- sourceId: z.string(),
52
- targetId: z.string(),
53
- type: z.enum(["BLOCKS", "BLOCKED_BY", "RELATES_TO"]),
31
+ items: z
32
+ .array(
33
+ z.object({
34
+ sourceId: z.string(),
35
+ targetId: z.string(),
36
+ type: edgeTypeSchema,
37
+ }),
38
+ )
39
+ .min(1),
54
40
  }),
55
41
  )
56
42
  .handler(async ({ data }) => {
57
43
  const request = getRequest();
58
- const session = await requireAuth(request);
44
+ await requireAuth(request);
59
45
 
60
- const existing = await prisma.storyDependency.findUnique({
61
- where: {
62
- sourceId_targetId_type: {
63
- sourceId: data.sourceId,
64
- targetId: data.targetId,
65
- type: data.type,
66
- },
67
- },
46
+ // 一次性取全部出边(3 类型合并;不按 projectId 裁剪——跨项目边同样参与环判定),
47
+ // 构建内存邻接表供本批循环的环检测复用,避免 O(items × 可达节点) 次串行 RTT
48
+ const allEdges = await prisma.storyDependency.findMany({
49
+ select: { sourceId: true, targetId: true },
68
50
  });
69
- if (existing) {
70
- throw new Error("依赖关系已存在");
51
+ const adjacency = new Map<string, string[]>();
52
+ for (const e of allEdges) {
53
+ const list = adjacency.get(e.sourceId);
54
+ if (list) list.push(e.targetId);
55
+ else adjacency.set(e.sourceId, [e.targetId]);
71
56
  }
72
57
 
73
- const dep = await prisma.storyDependency.create({
74
- data: {
75
- sourceId: data.sourceId,
76
- targetId: data.targetId,
77
- type: data.type,
78
- },
79
- });
58
+ // 环检测:从 fromId 出发沿 source→target 有向边能否到达 toId(纯内存遍历)
59
+ // batchEdges:本批已插入边(防批内互指环漏检)
60
+ function reaches(
61
+ fromId: string,
62
+ toId: string,
63
+ batchEdges: Array<{ sourceId: string; targetId: string }>,
64
+ ): boolean {
65
+ const visited = new Set<string>();
66
+ const stack = [fromId];
67
+ while (stack.length > 0) {
68
+ const current = stack.pop()!;
69
+ if (current === toId) return true;
70
+ if (visited.has(current)) continue;
71
+ visited.add(current);
72
+ for (const next of adjacency.get(current) ?? []) {
73
+ if (!visited.has(next)) stack.push(next);
74
+ }
75
+ for (const e of batchEdges) {
76
+ if (e.sourceId === current && !visited.has(e.targetId)) stack.push(e.targetId);
77
+ }
78
+ }
79
+ return false;
80
+ }
80
81
 
81
- return dep;
82
+ const created: string[] = [];
83
+ const failed: Array<{ targetId: string; reason: "SELF" | "DUPLICATED" | "CYCLE" }> = [];
84
+ const batchEdges: Array<{ sourceId: string; targetId: string }> = [];
85
+
86
+ for (const item of data.items) {
87
+ if (item.sourceId === item.targetId) {
88
+ failed.push({ targetId: item.targetId, reason: "SELF" });
89
+ continue;
90
+ }
91
+ const existing = await prisma.storyDependency.findFirst({
92
+ where: {
93
+ OR: [
94
+ { sourceId: item.sourceId, targetId: item.targetId },
95
+ { sourceId: item.targetId, targetId: item.sourceId },
96
+ ],
97
+ },
98
+ select: { id: true },
99
+ });
100
+ if (existing) {
101
+ failed.push({ targetId: item.targetId, reason: "DUPLICATED" });
102
+ continue;
103
+ }
104
+ // 插入 (S,T) 后成环 ⇔ 已存在路径 T →…→ S(含本批边)
105
+ if (reaches(item.targetId, item.sourceId, batchEdges)) {
106
+ failed.push({ targetId: item.targetId, reason: "CYCLE" });
107
+ continue;
108
+ }
109
+ const dep = await prisma.storyDependency.create({ data: item });
110
+ created.push(dep.id);
111
+ batchEdges.push({ sourceId: item.sourceId, targetId: item.targetId });
112
+ }
113
+
114
+ return { created, failed };
82
115
  });
83
116
 
84
117
  export const removeDependencyFn = createServerFn({ method: "POST" })
85
- .validator(
86
- z.object({
87
- sourceId: z.string(),
88
- targetId: z.string(),
89
- type: z.enum(["BLOCKS", "BLOCKED_BY", "RELATES_TO"]),
90
- }),
91
- )
118
+ .validator(z.object({ depId: z.string() }))
92
119
  .handler(async ({ data }) => {
93
120
  const request = getRequest();
94
- const session = await requireAuth(request);
95
-
96
- const dep = await prisma.storyDependency.delete({
97
- where: {
98
- sourceId_targetId_type: {
99
- sourceId: data.sourceId,
100
- targetId: data.targetId,
101
- type: data.type,
102
- },
103
- },
104
- });
105
-
106
- return dep;
121
+ await requireAuth(request);
122
+ return prisma.storyDependency.delete({ where: { id: data.depId } });
107
123
  });
108
124
 
109
125
  export const getDependenciesFn = createServerFn({ method: "GET" })
110
126
  .validator(z.object({ storyId: z.string() }))
111
127
  .handler(async ({ data }) => {
112
128
  const request = getRequest();
113
- const session = await requireAuth(request);
129
+ await requireAuth(request);
114
130
 
115
131
  const deps = await prisma.storyDependency.findMany({
116
- where: {
117
- OR: [{ sourceId: data.storyId }, { targetId: data.storyId }],
118
- },
119
- include: {
120
- source: {
121
- select: { id: true, name: true, state: true },
122
- },
123
- target: {
124
- select: { id: true, name: true, state: true },
125
- },
126
- },
132
+ where: { OR: [{ targetId: data.storyId }, { sourceId: data.storyId }] },
133
+ select: { id: true, sourceId: true, targetId: true, type: true },
127
134
  });
128
-
129
- const grouped = {
130
- blocking: deps
131
- .filter((d) => d.sourceId === data.storyId && d.type === "BLOCKS")
132
- .map((d) => ({
133
- id: d.target.id,
134
- title: d.target.name,
135
- status: d.target.state,
136
- type: "blocks" as DependencyType,
137
- })),
138
- blockedBy: deps
139
- .filter((d) => d.targetId === data.storyId && d.type === "BLOCKS")
140
- .map((d) => ({
141
- id: d.source.id,
142
- title: d.source.name,
143
- status: d.source.state,
144
- type: "blocked_by" as DependencyType,
145
- })),
146
- relatesTo: deps
147
- .filter((d) => d.type === "RELATES_TO")
148
- .map((d) => {
149
- const isSource = d.sourceId === data.storyId;
150
- const other = isSource ? d.target : d.source;
151
- return {
152
- id: other.id,
153
- title: other.name,
154
- status: other.state,
155
- type: "relates_to" as DependencyType,
156
- };
157
- }),
158
- };
159
-
160
- return grouped;
161
- });
162
-
163
- export const validateDependencyFn = createServerFn({ method: "POST" })
164
- .validator(
165
- z.object({
166
- sourceId: z.string(),
167
- targetId: z.string(),
168
- type: z.enum(["BLOCKS", "BLOCKED_BY", "RELATES_TO"]),
169
- }),
170
- )
171
- .handler(async ({ data }) => {
172
- const request = getRequest();
173
- const session = await requireAuth(request);
174
-
175
- if (data.sourceId === data.targetId) {
176
- return { valid: false, reason: "不能创建自我依赖" };
177
- }
178
-
179
- if (data.type === "BLOCKS") {
180
- const reverse = await prisma.storyDependency.findUnique({
181
- where: {
182
- sourceId_targetId_type: {
183
- sourceId: data.targetId,
184
- targetId: data.sourceId,
185
- type: "BLOCKS",
186
- },
187
- },
188
- });
189
- if (!reverse) {
190
- return {
191
- valid: false,
192
- reason: "blocks 依赖必须成对出现(需要同时创建反向 blocks)",
193
- };
194
- }
135
+ if (deps.length === 0) {
136
+ return { dependsOn: [] as DependencyRow[], dependents: [] as DependencyRow[] };
195
137
  }
196
138
 
197
- const source = await prisma.story.findUnique({
198
- where: { id: data.sourceId },
199
- select: { parentId: true },
139
+ const ids = [...new Set(deps.flatMap((d) => [d.sourceId, d.targetId]))];
140
+ const stories = await prisma.story.findMany({
141
+ where: { id: { in: ids } },
142
+ select: STORY_LIST_SELECT,
200
143
  });
201
- const target = await prisma.story.findUnique({
202
- where: { id: data.targetId },
203
- select: { parentId: true },
204
- });
205
-
206
- if (source?.parentId === data.targetId || target?.parentId === data.sourceId) {
207
- return {
208
- valid: false,
209
- reason: "父子需求之间不能有 blocks/blocked_by 依赖",
210
- };
211
- }
144
+ const byId = new Map(stories.map((s) => [s.id, castStoryUsers(s)]));
212
145
 
213
- if (await hasCycle(data.sourceId, data.targetId)) {
214
- return { valid: false, reason: "会形成循环依赖" };
215
- }
146
+ const build = (d: (typeof deps)[number], storyId: string): DependencyRow | null => {
147
+ const otherId = d.sourceId === storyId ? d.targetId : d.sourceId;
148
+ const s = byId.get(otherId);
149
+ return s ? ({ ...s, depId: d.id, depType: d.type } as DependencyRow) : null;
150
+ };
216
151
 
217
- return { valid: true };
152
+ return {
153
+ dependsOn: deps
154
+ .filter((d) => d.targetId === data.storyId)
155
+ .map((d) => build(d, data.storyId))
156
+ .filter((r): r is DependencyRow => r !== null),
157
+ dependents: deps
158
+ .filter((d) => d.sourceId === data.storyId)
159
+ .map((d) => build(d, data.storyId))
160
+ .filter((r): r is DependencyRow => r !== null),
161
+ };
218
162
  });
@@ -78,9 +78,11 @@ export const listStoriesInGroupFn = createServerFn({ method: "GET" })
78
78
 
79
79
  const field = ORDER_FIELD[data.orderBy];
80
80
  const dir: "asc" | "desc" = data.orderDir === "asc" ? "asc" : "desc";
81
- const orderBy: Prisma.StoryOrderByWithRelationInput = field
81
+ const primary: Prisma.StoryOrderByWithRelationInput = field
82
82
  ? { [field]: dir }
83
83
  : { createdAt: dir };
84
+ // id tiebreaker:主排序键相同的行(同批创建等)顺序必须确定,否则重拉时顺序抖动
85
+ const orderBy: Prisma.StoryOrderByWithRelationInput[] = [primary, { id: "asc" }];
84
86
 
85
87
  const items = await prisma.story.findMany({
86
88
  where,
@@ -132,9 +134,11 @@ export const listAllGroupsItemsFn = createServerFn({ method: "GET" })
132
134
  const base = buildListWhere({ ...data, projectId });
133
135
  const field = ORDER_FIELD[data.orderBy];
134
136
  const dir: "asc" | "desc" = data.orderDir === "asc" ? "asc" : "desc";
135
- const orderBy: Prisma.StoryOrderByWithRelationInput = field
137
+ const primary: Prisma.StoryOrderByWithRelationInput = field
136
138
  ? { [field]: dir }
137
139
  : { createdAt: dir };
140
+ // id tiebreaker:与 listStoriesInGroupFn 保持一致,保证分页跨批次全序稳定
141
+ const orderBy: Prisma.StoryOrderByWithRelationInput[] = [primary, { id: "asc" }];
138
142
 
139
143
  const batchTake = data.take * data.groupCount;
140
144
  const items = await prisma.story.findMany({
@@ -216,6 +220,19 @@ function deriveGroupKey(groupBy: GroupKey, row: Record<string, unknown>): string
216
220
  }
217
221
  }
218
222
 
223
+ /** 组间显示顺序与筛选 pop 的值顺序一致(state/priority 按枚举序,其余维度保持 DB 返回序;未知 key 排最后) */
224
+ const GROUP_DISPLAY_ORDER: Partial<Record<GroupKey, readonly string[]>> = {
225
+ state: STORY_STATES.map((s) => s.value),
226
+ priority: STORY_PRIORITIES.map((p) => p.value),
227
+ };
228
+
229
+ function sortGroupKeys(groupBy: GroupKey, keys: string[]): string[] {
230
+ const order = GROUP_DISPLAY_ORDER[groupBy];
231
+ if (!order) return keys;
232
+ const rank = new Map(order.map((k, i) => [k, i]));
233
+ return [...keys].sort((a, b) => (rank.get(a) ?? order.length) - (rank.get(b) ?? order.length));
234
+ }
235
+
219
236
  export const listStoryGroupsFn = createServerFn({ method: "GET" })
220
237
  .validator(listGroupsSchema)
221
238
  .handler(async ({ data }) => {
@@ -293,6 +310,9 @@ export const listStoryGroupsFn = createServerFn({ method: "GET" })
293
310
  if (!keys.includes(k)) keys.push(k);
294
311
  }
295
312
  }
296
- const groups: Array<{ key: string; total: number }> = keys.map((k) => ({ key: k, total: totalMap.get(k) ?? 0 }));
313
+ const groups: Array<{ key: string; total: number }> = sortGroupKeys(data.groupBy, keys).map((k) => ({
314
+ key: k,
315
+ total: totalMap.get(k) ?? 0,
316
+ }));
297
317
  return { groups };
298
318
  });
@@ -349,7 +349,8 @@ const listProjectStoriesSchema = z.object({
349
349
  search: z.string().optional(),
350
350
  cursor: z.string().optional(),
351
351
  excludeStoryId: z.string().optional(),
352
- mode: z.enum(["parent", "child"]).default("parent"),
352
+ excludeIds: z.array(z.string()).optional(),
353
+ mode: z.enum(["parent", "child", "dependency"]).default("parent"),
353
354
  pageSize: z.number().int().min(1).max(50).default(20),
354
355
  });
355
356
 
@@ -365,16 +366,17 @@ export const listProjectStoriesFn = createServerFn({ method: "GET" })
365
366
  if (data.mode === "parent") {
366
367
  const descendants = await collectDescendantIds(data.excludeStoryId);
367
368
  for (const id of descendants) excludeIds.add(id);
368
- } else {
369
+ } else if (data.mode === "child") {
369
370
  const ancestors = await collectAncestorIds(data.excludeStoryId);
370
371
  for (const id of ancestors) excludeIds.add(id);
371
372
  }
372
373
  }
374
+ for (const id of data.excludeIds ?? []) excludeIds.add(id);
373
375
 
374
376
  const stories = await prisma.story.findMany({
375
377
  where: {
376
378
  ...(data.projectId ? { projectId: data.projectId } : {}),
377
- parentId: null,
379
+ ...(data.mode === "dependency" ? {} : { parentId: null }),
378
380
  archived: false,
379
381
  ...(excludeIds.size > 0
380
382
  ? { id: { notIn: Array.from(excludeIds) } }
@@ -183,7 +183,7 @@ export const clearExpiredInvitationsFn = createServerFn({ method: 'POST' })
183
183
  const session = await requireAuth(request)
184
184
  const ownerId = session.user!.id!
185
185
  const res = await prisma.teamInvitation.deleteMany({
186
- where: { ownerId, status: 'expired' },
186
+ where: { ownerId, status: 'expired', channel: 'email' },
187
187
  })
188
188
  await auditLog({
189
189
  workspaceId: '',
@@ -279,8 +279,9 @@ export const refreshTeamInvitationLinkFn = createServerFn({ method: 'POST' })
279
279
  },
280
280
  data: { status: 'invalidated', processedAt: new Date() },
281
281
  })
282
- await tx.teamInvitation.deleteMany({
282
+ await tx.teamInvitation.updateMany({
283
283
  where: { ownerId, channel: 'link', status: { in: ['pending', 'pending_approval'] } },
284
+ data: { status: 'revoked' },
284
285
  })
285
286
  return tx.teamInvitation.create({
286
287
  data: {
@@ -493,6 +494,54 @@ export const rejectTeamJoinRequestFn = createServerFn({ method: 'POST' })
493
494
  return { success: true }
494
495
  })
495
496
 
497
+ export const deleteTeamInvitationFn = createServerFn({ method: 'POST' })
498
+ .validator(revokeSchema)
499
+ .handler(async ({ data }) => {
500
+ const request = getRequest()
501
+ const session = await requireAuth(request)
502
+ const ownerId = session.user!.id!
503
+
504
+ const invRow = await prisma.teamInvitation.findUnique({ where: { id: data.invitationId } })
505
+ if (!invRow || invRow.ownerId !== ownerId) throw new Error('邀请不存在')
506
+ // link 渠道行下挂 joinRequest(级联删除),禁止单独删除
507
+ if (invRow.channel === 'link') throw new Error('链接邀请记录不可删除')
508
+
509
+ await prisma.teamInvitation.delete({ where: { id: invRow.id } })
510
+
511
+ await auditLog({
512
+ workspaceId: '',
513
+ userId: ownerId,
514
+ resourceType: 'teamInvitation',
515
+ resourceId: invRow.id,
516
+ action: 'team.invite.delete',
517
+ metadata: { email: invRow.email },
518
+ })
519
+ return { success: true }
520
+ })
521
+
522
+ export const deleteTeamJoinRequestFn = createServerFn({ method: 'POST' })
523
+ .validator(joinRequestSchema)
524
+ .handler(async ({ data }) => {
525
+ const request = getRequest()
526
+ const session = await requireAuth(request)
527
+ const ownerId = session.user!.id!
528
+
529
+ const reqRow = await prisma.teamJoinRequest.findUnique({ where: { id: data.requestId } })
530
+ if (!reqRow || reqRow.ownerId !== ownerId) throw new Error('申请不存在')
531
+
532
+ await prisma.teamJoinRequest.delete({ where: { id: reqRow.id } })
533
+
534
+ await auditLog({
535
+ workspaceId: '',
536
+ userId: ownerId,
537
+ resourceType: 'teamJoinRequest',
538
+ resourceId: reqRow.id,
539
+ action: 'team.join.delete',
540
+ metadata: { email: reqRow.email },
541
+ })
542
+ return { success: true }
543
+ })
544
+
496
545
  export const listTeamUsersFn = createServerFn({ method: 'GET' }).handler(async () => {
497
546
  const request = getRequest()
498
547
  const session = await requireAuth(request)
@@ -1,148 +0,0 @@
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 { useState } from 'react'
19
- import { useNavigate } from '@tanstack/react-router'
20
- import { Globe, Lock, Trash2 } from 'lucide-react'
21
- import {
22
- Dialog,
23
- DialogContent,
24
- DialogDescription,
25
- DialogFooter,
26
- DialogHeader,
27
- DialogTitle,
28
- } from '@/components/ui/dialog'
29
- import { Button } from '@/components/ui/button'
30
- import { Label } from '@/components/ui/label'
31
- import {
32
- Select,
33
- SelectContent,
34
- SelectItem,
35
- SelectTrigger,
36
- SelectValue,
37
- } from '@/components/ui/select'
38
- import { deleteKnowledgeFn, updateKnowledgeFn } from '@/server/serverFns/knowledge'
39
- import { useTranslation } from 'react-i18next'
40
-
41
- interface KbData {
42
- id: string
43
- name: string
44
- visibility?: string
45
- }
46
-
47
- interface KnowledgeSettingsPanelProps {
48
- open: boolean
49
- onOpenChange: (open: boolean) => void
50
- kb: KbData
51
- onUpdate?: () => void
52
- }
53
-
54
- export function KnowledgeSettingsPanel({ open, onOpenChange, kb, onUpdate }: KnowledgeSettingsPanelProps) {
55
- const { t } = useTranslation('knowledge')
56
- const navigate = useNavigate()
57
- const [visibility, setVisibility] = useState((kb as any).visibility || 'private')
58
- const [deleting, setDeleting] = useState(false)
59
- const [saving, setSaving] = useState(false)
60
-
61
- const handleVisibilityChange = async (value: string) => {
62
- const v = value as 'private' | 'team'
63
- setVisibility(v)
64
- setSaving(true)
65
- try {
66
- await updateKnowledgeFn({ data: { id: kb.id, visibility: v } })
67
- onUpdate?.()
68
- } catch (e) {
69
- console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
70
- }
71
- setSaving(false)
72
- }
73
-
74
- const handleDelete = async () => {
75
- setDeleting(true)
76
- try {
77
- await deleteKnowledgeFn({ data: { id: kb.id } })
78
- onOpenChange(false)
79
- navigate({ to: '/knowledge' })
80
- } catch (e) {
81
- console.error('Delete failed:', e instanceof Error ? e.message : String(e))
82
- }
83
- setDeleting(false)
84
- }
85
-
86
- return (
87
- <Dialog open={open} onOpenChange={onOpenChange}>
88
- <DialogContent className="sm:max-w-md">
89
- <DialogHeader>
90
- <DialogTitle>{t('kbSettings.title')}</DialogTitle>
91
- <DialogDescription>{t('kbSettings.subtitle', { name: kb.name })}</DialogDescription>
92
- </DialogHeader>
93
-
94
- <div className="space-y-6 py-4">
95
- <div className="space-y-2">
96
- <Label>{t('kbSettings.ownership')}</Label>
97
- <Select value={visibility} onValueChange={handleVisibilityChange} disabled={saving}>
98
- <SelectTrigger>
99
- <SelectValue />
100
- </SelectTrigger>
101
- <SelectContent>
102
- <SelectItem value="private">
103
- <div className="flex items-center gap-2">
104
- <Lock className="size-4" />
105
- {t('kbSettings.private')}
106
- </div>
107
- </SelectItem>
108
- <SelectItem value="team">
109
- <div className="flex items-center gap-2">
110
- <Globe className="size-4" />
111
- {t('kbSettings.team')}
112
- </div>
113
- </SelectItem>
114
- </SelectContent>
115
- </Select>
116
- <p className="text-xs text-muted-foreground">
117
- {t('kbSettings.teamHint')}
118
- </p>
119
- </div>
120
-
121
- <div className="border-t pt-4">
122
- <div className="space-y-2">
123
- <Label className="text-destructive">{t('kbSettings.danger')}</Label>
124
- <p className="text-xs text-muted-foreground">
125
- {t('kbSettings.deleteHint')}
126
- </p>
127
- <Button
128
- variant="destructive"
129
- size="sm"
130
- onClick={handleDelete}
131
- disabled={deleting}
132
- >
133
- <Trash2 className="size-4 mr-1" />
134
- {deleting ? t('kbSettings.deleting') : t('kbSettings.delete')}
135
- </Button>
136
- </div>
137
- </div>
138
- </div>
139
-
140
- <DialogFooter>
141
- <Button variant="outline" onClick={() => onOpenChange(false)}>
142
- {t('kbSettings.close')}
143
- </Button>
144
- </DialogFooter>
145
- </DialogContent>
146
- </Dialog>
147
- )
148
- }