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