@allbluecn/web-app 0.4.17 → 0.4.19
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 +11 -11
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/config/sidebar-routes.tsx +16 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/routeTree.gen.ts +47 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/team.ts +521 -53
- package/vite.shared.ts +13 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
4
|
+
createServerFn: (config: any) => {
|
|
5
|
+
const api: any = {
|
|
6
|
+
_validator: undefined,
|
|
7
|
+
validator(schema: any) {
|
|
8
|
+
this._validator = schema
|
|
9
|
+
return this
|
|
10
|
+
},
|
|
11
|
+
handler(fn: Function) {
|
|
12
|
+
const wrapped = async (args: any) => {
|
|
13
|
+
let data = args?.data
|
|
14
|
+
if (this._validator && data !== undefined) {
|
|
15
|
+
data = this._validator.parse ? this._validator.parse(data) : data
|
|
16
|
+
}
|
|
17
|
+
return fn({ data })
|
|
18
|
+
}
|
|
19
|
+
return wrapped
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
return api
|
|
23
|
+
},
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
27
|
+
getRequest: vi.fn(),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
vi.mock('@allbluecn/database', () => ({
|
|
31
|
+
prisma: {
|
|
32
|
+
user: { findUnique: vi.fn() },
|
|
33
|
+
teamInvitation: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), create: vi.fn(), findMany: vi.fn() },
|
|
34
|
+
teamMembership: { findUnique: vi.fn(), create: vi.fn() },
|
|
35
|
+
teamJoinRequest: { findUnique: vi.fn(), upsert: vi.fn(), update: vi.fn(), updateMany: vi.fn(), findMany: vi.fn() },
|
|
36
|
+
$transaction: vi.fn(),
|
|
37
|
+
},
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
41
|
+
requireAuth: vi.fn().mockResolvedValue({ user: { id: 'u2', email: 'b@x.com' } }),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
vi.mock('@/server/audit', () => ({
|
|
45
|
+
auditLog: vi.fn(),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
vi.mock('@/server/resources', () => ({
|
|
49
|
+
requireTeamSeats: vi.fn().mockResolvedValue(undefined),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
import { prisma } from '@allbluecn/database'
|
|
53
|
+
import { applyTeamInvitationFn, approveTeamJoinRequestFn, rejectTeamJoinRequestFn, getTeamInvitationByTokenFn, refreshTeamInvitationLinkFn, listTeamInvitationsFn } from '../team'
|
|
54
|
+
|
|
55
|
+
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(prisma))
|
|
56
|
+
|
|
57
|
+
const LINK_INV = {
|
|
58
|
+
id: 'inv1', token: 't1', status: 'pending', seatType: 'member', channel: 'link',
|
|
59
|
+
email: null, expiresAt: new Date(Date.now() + 60000), ownerId: 'owner1',
|
|
60
|
+
owner: { id: 'owner1', username: 'ow', email: 'owner@x.com' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('applyTeamInvitationFn(link 渠道)', () => {
|
|
64
|
+
beforeEach(() => vi.clearAllMocks())
|
|
65
|
+
|
|
66
|
+
it('链接不存在 → 拒绝', async () => {
|
|
67
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(null as never)
|
|
68
|
+
await expect(applyTeamInvitationFn({ data: { token: 'tX', email: 'a@x.com' } }))
|
|
69
|
+
.rejects.toMatchObject({ message: expect.stringContaining('不存在') })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('链接过期 → 拒绝', async () => {
|
|
73
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
|
|
74
|
+
await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'a@x.com' } }))
|
|
75
|
+
.rejects.toMatchObject({ message: expect.stringContaining('过期') })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('邮箱是 owner 自己 → 拒绝(不能加入自己的团队)', async () => {
|
|
79
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
|
|
80
|
+
await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'owner@x.com' } }))
|
|
81
|
+
.rejects.toMatchObject({ message: expect.stringContaining('自己的团队') })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('邮箱已注册且该账号已加入团队 → 拒绝', async () => {
|
|
85
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
|
|
86
|
+
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
|
|
87
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1' } as never)
|
|
88
|
+
await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'taken@x.com' } }))
|
|
89
|
+
.rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('新邮箱 → 创建 TeamJoinRequest,且不改 TeamInvitation 状态', async () => {
|
|
93
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
|
|
94
|
+
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never)
|
|
95
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
|
|
96
|
+
vi.mocked(prisma.teamJoinRequest.upsert).mockResolvedValue({ id: 'jr1' } as never)
|
|
97
|
+
|
|
98
|
+
const res = await applyTeamInvitationFn({ data: { token: 't1', email: 'new@x.com' } })
|
|
99
|
+
|
|
100
|
+
expect(res).toMatchObject({ success: true, requiresApproval: true })
|
|
101
|
+
expect(prisma.teamJoinRequest.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
|
102
|
+
where: { invitationId_email: { invitationId: 'inv1', email: 'new@x.com' } },
|
|
103
|
+
}))
|
|
104
|
+
expect(prisma.teamInvitation.update).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('同链接同邮箱已有 pending 申请 → 幂等返回,不重复创建', async () => {
|
|
108
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
|
|
109
|
+
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never)
|
|
110
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ id: 'jr1', status: 'pending' } as never)
|
|
111
|
+
|
|
112
|
+
const res = await applyTeamInvitationFn({ data: { token: 't1', email: 'dup@x.com' } })
|
|
113
|
+
|
|
114
|
+
expect(res).toMatchObject({ success: true, duplicate: true })
|
|
115
|
+
expect(prisma.teamJoinRequest.upsert).not.toHaveBeenCalled()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const PENDING_REQ = {
|
|
120
|
+
id: 'jr1', invitationId: 'inv1', ownerId: 'u2', userId: 'u9',
|
|
121
|
+
email: 'new@x.com', status: 'pending', seatType: 'member',
|
|
122
|
+
invitation: { id: 'inv1', token: 't1', status: 'pending', channel: 'link', expiresAt: new Date(Date.now() + 60000) },
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('approveTeamJoinRequestFn', () => {
|
|
126
|
+
beforeEach(() => vi.clearAllMocks())
|
|
127
|
+
|
|
128
|
+
it('申请不存在或非本人 → 拒绝', async () => {
|
|
129
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
|
|
130
|
+
await expect(approveTeamJoinRequestFn({ data: { requestId: 'jrX', seatType: 'member' } }))
|
|
131
|
+
.rejects.toMatchObject({ message: expect.stringContaining('申请不存在') })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('申请人已加入其他团队 → 友好拒绝', async () => {
|
|
135
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
|
|
136
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'mX' } as never)
|
|
137
|
+
await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
|
|
138
|
+
.rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('链接已过期 → 拒绝并同步失效申请', async () => {
|
|
142
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({
|
|
143
|
+
...PENDING_REQ, invitation: { ...PENDING_REQ.invitation, expiresAt: new Date(Date.now() - 1000) },
|
|
144
|
+
} as never)
|
|
145
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
|
|
146
|
+
await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
|
|
147
|
+
.rejects.toMatchObject({ message: expect.stringContaining('过期') })
|
|
148
|
+
expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
149
|
+
data: expect.objectContaining({ status: 'invalidated' }),
|
|
150
|
+
}))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('批准 → 事务创建 membership 并置 approved', async () => {
|
|
154
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
|
|
155
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
|
|
156
|
+
vi.mocked(prisma.teamMembership.create).mockResolvedValue({ id: 'm1' } as never)
|
|
157
|
+
vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
|
|
158
|
+
|
|
159
|
+
const res = await approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'guest' } })
|
|
160
|
+
|
|
161
|
+
expect(res).toEqual({ success: true })
|
|
162
|
+
expect(prisma.teamMembership.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
163
|
+
data: { ownerId: 'u2', memberId: 'u9', seatType: 'guest' },
|
|
164
|
+
}))
|
|
165
|
+
expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
166
|
+
where: { id: 'jr1' },
|
|
167
|
+
data: expect.objectContaining({ status: 'approved', seatType: 'guest' }),
|
|
168
|
+
}))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('userId 为空时按 email 回查申请人', async () => {
|
|
172
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, userId: null } as never)
|
|
173
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
|
|
174
|
+
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
|
|
175
|
+
vi.mocked(prisma.teamMembership.create).mockResolvedValue({ id: 'm1' } as never)
|
|
176
|
+
vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
|
|
177
|
+
|
|
178
|
+
await approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } })
|
|
179
|
+
|
|
180
|
+
expect(prisma.user.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { email: 'new@x.com' } }))
|
|
181
|
+
expect(prisma.teamMembership.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
182
|
+
data: expect.objectContaining({ memberId: 'u9' }),
|
|
183
|
+
}))
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('rejectTeamJoinRequestFn', () => {
|
|
188
|
+
beforeEach(() => vi.clearAllMocks())
|
|
189
|
+
|
|
190
|
+
it('拒绝 → 置 rejected 并记录 processedAt', async () => {
|
|
191
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
|
|
192
|
+
vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
|
|
193
|
+
|
|
194
|
+
await rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } })
|
|
195
|
+
|
|
196
|
+
expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
197
|
+
where: { id: 'jr1' },
|
|
198
|
+
data: expect.objectContaining({ status: 'rejected' }),
|
|
199
|
+
}))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('非 pending 状态 → 拒绝操作', async () => {
|
|
203
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, status: 'approved' } as never)
|
|
204
|
+
await expect(rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } }))
|
|
205
|
+
.rejects.toMatchObject({ message: expect.stringContaining('待审批') })
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('getTeamInvitationByTokenFn(link 渠道)', () => {
|
|
210
|
+
beforeEach(() => vi.clearAllMocks())
|
|
211
|
+
|
|
212
|
+
it('登录用户有 pending 申请 → 返回 myRequestStatus=pending', async () => {
|
|
213
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
|
|
214
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
|
|
215
|
+
vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: 'new@x.com' } as never)
|
|
216
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ status: 'pending' } as never)
|
|
217
|
+
|
|
218
|
+
const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: 'u9' } })
|
|
219
|
+
|
|
220
|
+
expect(res).toMatchObject({ effectiveStatus: 'pending', myRequestStatus: 'pending', alreadyJoined: false })
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('链接过期 → 置 expired 并 invalid 关联 pending 申请', async () => {
|
|
224
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
|
|
225
|
+
vi.mocked(prisma.teamInvitation.update).mockResolvedValue({} as never)
|
|
226
|
+
vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 2 } as never)
|
|
227
|
+
|
|
228
|
+
const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: null } })
|
|
229
|
+
|
|
230
|
+
expect(res).toMatchObject({ effectiveStatus: 'expired' })
|
|
231
|
+
expect(prisma.teamJoinRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
232
|
+
where: { invitationId: 'inv1', status: 'pending' },
|
|
233
|
+
}))
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('登录用户已加入团队 → alreadyJoined=true', async () => {
|
|
237
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
|
|
238
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ ownerId: 'other' } as never)
|
|
239
|
+
|
|
240
|
+
const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: 'u9' } })
|
|
241
|
+
|
|
242
|
+
expect(res).toMatchObject({ alreadyJoined: true, joinedOwnerId: 'other', myRequestStatus: null })
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('refreshTeamInvitationLinkFn', () => {
|
|
247
|
+
beforeEach(() => vi.clearAllMocks())
|
|
248
|
+
|
|
249
|
+
it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接删除,新链接创建', async () => {
|
|
250
|
+
vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
|
|
251
|
+
vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 1 } as never)
|
|
252
|
+
vi.mocked(prisma.teamInvitation.create).mockResolvedValue({ id: 'inv2', token: 'NEW' } as never)
|
|
253
|
+
|
|
254
|
+
const res = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 7 } })
|
|
255
|
+
|
|
256
|
+
expect(res).toMatchObject({ success: true })
|
|
257
|
+
expect(prisma.teamJoinRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
258
|
+
where: expect.objectContaining({ ownerId: 'u2', status: 'pending' }),
|
|
259
|
+
data: expect.objectContaining({ status: 'invalidated' }),
|
|
260
|
+
}))
|
|
261
|
+
expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
262
|
+
where: { ownerId: 'u2', channel: 'link', status: { in: ['pending', 'pending_approval'] } },
|
|
263
|
+
}))
|
|
264
|
+
expect(prisma.teamInvitation.create).toHaveBeenCalled()
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('listTeamInvitationsFn', () => {
|
|
269
|
+
beforeEach(() => vi.clearAllMocks())
|
|
270
|
+
|
|
271
|
+
it('返回 { invitations, joinRequests } 结构', async () => {
|
|
272
|
+
vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 0 } as never)
|
|
273
|
+
vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
|
|
274
|
+
vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([{ id: 'inv1' }] as never)
|
|
275
|
+
vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([{ id: 'jr1' }] as never)
|
|
276
|
+
|
|
277
|
+
const res = await listTeamInvitationsFn()
|
|
278
|
+
|
|
279
|
+
expect(res).toEqual({ invitations: [{ id: 'inv1' }], joinRequests: [{ id: 'jr1' }] })
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -33,10 +33,21 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
33
33
|
teamMembership: {
|
|
34
34
|
findUnique: vi.fn(),
|
|
35
35
|
findMany: vi.fn(),
|
|
36
|
+
findFirst: vi.fn(),
|
|
36
37
|
delete: vi.fn(),
|
|
38
|
+
create: vi.fn(),
|
|
37
39
|
count: vi.fn(),
|
|
38
40
|
},
|
|
39
|
-
teamInvitation: {
|
|
41
|
+
teamInvitation: {
|
|
42
|
+
create: vi.fn(),
|
|
43
|
+
findFirst: vi.fn(),
|
|
44
|
+
findUnique: vi.fn(),
|
|
45
|
+
updateMany: vi.fn(),
|
|
46
|
+
findMany: vi.fn(),
|
|
47
|
+
update: vi.fn(),
|
|
48
|
+
count: vi.fn(),
|
|
49
|
+
deleteMany: vi.fn(),
|
|
50
|
+
},
|
|
40
51
|
projectMember: { deleteMany: vi.fn() },
|
|
41
52
|
$transaction: vi.fn(),
|
|
42
53
|
},
|
|
@@ -55,17 +66,13 @@ vi.mock('@/server/audit', () => ({
|
|
|
55
66
|
}))
|
|
56
67
|
|
|
57
68
|
import { prisma } from '@allbluecn/database'
|
|
58
|
-
import {
|
|
59
|
-
listTeamMembersFn,
|
|
60
|
-
removeTeamMemberFn,
|
|
61
|
-
leaveTeamFn,
|
|
62
|
-
listTeamUsersFn,
|
|
63
|
-
} from '../team'
|
|
69
|
+
import { listTeamMembersFn } from '../team'
|
|
64
70
|
|
|
65
71
|
describe('listTeamMembersFn', () => {
|
|
66
72
|
beforeEach(() => vi.clearAllMocks())
|
|
67
73
|
|
|
68
|
-
it('
|
|
74
|
+
it('已有 membership → 正常列出成员', async () => {
|
|
75
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
|
|
69
76
|
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
|
|
70
77
|
{
|
|
71
78
|
id: 'm1',
|
|
@@ -78,124 +85,38 @@ describe('listTeamMembersFn', () => {
|
|
|
78
85
|
] as never)
|
|
79
86
|
const result = await listTeamMembersFn()
|
|
80
87
|
expect(result).toHaveLength(1)
|
|
81
|
-
expect(result[0].seatType).toBe('member')
|
|
88
|
+
expect((result as any)[0].seatType).toBe('member')
|
|
89
|
+
expect((result as any)[0].memberId).toBe('u2')
|
|
82
90
|
})
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
describe('removeTeamMemberFn', () => {
|
|
86
|
-
beforeEach(() => vi.clearAllMocks())
|
|
87
|
-
|
|
88
|
-
it('owner 移除成员 → 事务删 membership + 级联删 ProjectMember', async () => {
|
|
89
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
90
|
-
id: 'm1',
|
|
91
|
-
ownerId: 'owner1',
|
|
92
|
-
memberId: 'u2',
|
|
93
|
-
seatType: 'member',
|
|
94
|
-
} as never)
|
|
95
|
-
let capturedTx: any
|
|
96
|
-
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
97
|
-
capturedTx = {
|
|
98
|
-
teamMembership: { delete: vi.fn().mockResolvedValue({}) },
|
|
99
|
-
projectMember: { deleteMany: vi.fn().mockResolvedValue({ count: 3 }) },
|
|
100
|
-
}
|
|
101
|
-
return fn(capturedTx)
|
|
102
|
-
})
|
|
103
|
-
const result = await removeTeamMemberFn({ data: { membershipId: 'm1' } })
|
|
104
|
-
expect(result.success).toBe(true)
|
|
105
|
-
expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
|
|
106
|
-
expect.objectContaining({ where: { userId: 'u2' } }),
|
|
107
|
-
)
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('非 owner 移除 → 拒绝', async () => {
|
|
111
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
112
|
-
id: 'm1',
|
|
113
|
-
ownerId: 'other',
|
|
114
|
-
memberId: 'u2',
|
|
115
|
-
} as never)
|
|
116
|
-
await expect(removeTeamMemberFn({ data: { membershipId: 'm1' } }))
|
|
117
|
-
.rejects.toMatchObject({ message: expect.stringContaining('权限') })
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('成员不存在 → 拒绝', async () => {
|
|
121
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
122
|
-
await expect(removeTeamMemberFn({ data: { membershipId: 'm999' } }))
|
|
123
|
-
.rejects.toMatchObject({ message: expect.stringContaining('不存在') })
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
describe('leaveTeamFn', () => {
|
|
128
|
-
beforeEach(() => vi.clearAllMocks())
|
|
129
91
|
|
|
130
|
-
it('
|
|
92
|
+
it('无 membership → 自动创建 owner 记录', async () => {
|
|
131
93
|
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
it('已加入团队 → 退出并级联删 ProjectMember', async () => {
|
|
136
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
137
|
-
id: 'm1',
|
|
94
|
+
vi.mocked(prisma.teamMembership.create).mockResolvedValue({
|
|
95
|
+
id: 'm-new',
|
|
138
96
|
ownerId: 'owner1',
|
|
139
97
|
memberId: 'owner1',
|
|
98
|
+
seatType: 'owner',
|
|
99
|
+
joinedAt: new Date(),
|
|
100
|
+
member: { id: 'owner1', username: 'A', email: 'a@x.com', avatarConfig: null, bgShape: null, bgColor: null },
|
|
140
101
|
} as never)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
return fn(capturedTx)
|
|
148
|
-
})
|
|
149
|
-
const result = await leaveTeamFn()
|
|
150
|
-
expect(result.success).toBe(true)
|
|
151
|
-
expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
|
|
152
|
-
expect.objectContaining({ where: { userId: 'owner1' } }),
|
|
102
|
+
const result = await listTeamMembersFn()
|
|
103
|
+
expect(prisma.teamMembership.create).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({
|
|
105
|
+
data: expect.objectContaining({ ownerId: 'owner1', memberId: 'owner1', seatType: 'owner' }),
|
|
106
|
+
}),
|
|
153
107
|
)
|
|
154
|
-
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
describe('listTeamUsersFn', () => {
|
|
158
|
-
beforeEach(() => vi.clearAllMocks())
|
|
159
|
-
|
|
160
|
-
it('返回 owner + active 成员(含 seatType)', async () => {
|
|
161
|
-
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
162
|
-
id: 'owner1',
|
|
163
|
-
username: 'A',
|
|
164
|
-
email: 'a@x.com',
|
|
165
|
-
avatarConfig: null,
|
|
166
|
-
bgShape: null,
|
|
167
|
-
bgColor: null,
|
|
168
|
-
} as never)
|
|
169
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
|
|
170
|
-
ownerId: 'owner1',
|
|
171
|
-
memberId: 'owner1',
|
|
172
|
-
} as never)
|
|
173
|
-
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
|
|
174
|
-
{ seatType: 'member', member: { id: 'u2', username: 'B', email: 'b@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
|
|
175
|
-
{ seatType: 'guest', member: { id: 'u3', username: 'C', email: 'c@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
|
|
176
|
-
] as never)
|
|
177
|
-
const result = await listTeamUsersFn()
|
|
178
|
-
expect(result).toHaveLength(3)
|
|
108
|
+
expect(result).toHaveLength(1)
|
|
179
109
|
expect(result[0].seatType).toBe('owner')
|
|
180
|
-
expect(result[0].
|
|
181
|
-
expect(result[1].seatType).toBe('member')
|
|
182
|
-
expect(result[2].seatType).toBe('guest')
|
|
110
|
+
expect(result[0].memberId).toBe('owner1')
|
|
183
111
|
})
|
|
184
112
|
|
|
185
|
-
it('
|
|
186
|
-
vi.mocked(prisma.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
} as never)
|
|
194
|
-
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
|
|
195
|
-
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([] as never)
|
|
196
|
-
const result = await listTeamUsersFn()
|
|
197
|
-
expect(result).toHaveLength(1)
|
|
198
|
-
expect(result[0].seatType).toBe('owner')
|
|
199
|
-
expect(result[0].username).toBe('Solo')
|
|
113
|
+
it('已有 membership → 不创建,直接查询', async () => {
|
|
114
|
+
vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
|
|
115
|
+
vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([])
|
|
116
|
+
await listTeamMembersFn()
|
|
117
|
+
expect(prisma.teamMembership.create).not.toHaveBeenCalled()
|
|
118
|
+
expect(prisma.teamMembership.findMany).toHaveBeenCalledWith(
|
|
119
|
+
expect.objectContaining({ where: { ownerId: 'owner1' } }),
|
|
120
|
+
)
|
|
200
121
|
})
|
|
201
|
-
})
|
|
122
|
+
})
|
package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts}
RENAMED
|
@@ -15,17 +15,12 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { listAiProvidersFn } from '@/server/serverFns/ai/providers'
|
|
21
|
-
import { getQueryClient } from '@allbluecn/shared/lib/query-client-seam'
|
|
18
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { prisma } from '@allbluecn/database'
|
|
22
20
|
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
queryKey: ['ai-providers'],
|
|
27
|
-
queryFn: () => listAiProvidersFn(),
|
|
28
|
-
getKey: (item) => item.meta.providerId,
|
|
29
|
-
queryClient: getQueryClient(),
|
|
21
|
+
export const checkAdminExistsFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
22
|
+
const count = await prisma.user.count({
|
|
23
|
+
where: { role: 'SUPER_ADMIN' },
|
|
30
24
|
})
|
|
31
|
-
|
|
25
|
+
return { hasAdmin: count > 0 }
|
|
26
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
3
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { prisma } from '@allbluecn/database'
|
|
6
|
+
import { requireAuth } from '@/server/auth-helpers'
|
|
7
|
+
import {
|
|
8
|
+
NOTIFICATION_CATEGORIES,
|
|
9
|
+
type NotificationCategory,
|
|
10
|
+
type CategoryPreferences,
|
|
11
|
+
type NotificationItem,
|
|
12
|
+
type NotificationListResult,
|
|
13
|
+
} from '@allbluecn/shared'
|
|
14
|
+
import {
|
|
15
|
+
getOrCreatePreferences,
|
|
16
|
+
DEFAULT_CATEGORIES,
|
|
17
|
+
} from '@/server/notifications/notification-service'
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 20
|
|
20
|
+
|
|
21
|
+
const listSchema = z.object({
|
|
22
|
+
category: z.enum(NOTIFICATION_CATEGORIES).optional(),
|
|
23
|
+
cursor: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const listNotificationsFn = createServerFn({ method: 'GET' })
|
|
27
|
+
.validator(listSchema)
|
|
28
|
+
.handler(async ({ data }): Promise<NotificationListResult> => {
|
|
29
|
+
const request = getRequest()
|
|
30
|
+
const session = await requireAuth(request)
|
|
31
|
+
const userId = session.user!.id!
|
|
32
|
+
|
|
33
|
+
const items = await prisma.notification.findMany({
|
|
34
|
+
where: {
|
|
35
|
+
userId,
|
|
36
|
+
...(data.category ? { category: data.category } : {}),
|
|
37
|
+
},
|
|
38
|
+
orderBy: { createdAt: 'desc' },
|
|
39
|
+
take: PAGE_SIZE + 1,
|
|
40
|
+
...(data.cursor ? { cursor: { id: data.cursor }, skip: 1 } : {}),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const hasMore = items.length > PAGE_SIZE
|
|
44
|
+
const page = (hasMore ? items.slice(0, PAGE_SIZE) : items).map(
|
|
45
|
+
(n): NotificationItem => ({
|
|
46
|
+
id: n.id,
|
|
47
|
+
category: n.category as NotificationCategory,
|
|
48
|
+
type: n.type,
|
|
49
|
+
title: n.title,
|
|
50
|
+
body: n.body,
|
|
51
|
+
link: n.link,
|
|
52
|
+
readAt: n.readAt ? n.readAt.toISOString() : null,
|
|
53
|
+
createdAt: n.createdAt.toISOString(),
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
return { items: page, total: page.length, hasMore }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const markReadSchema = z.object({ id: z.string().min(1) })
|
|
60
|
+
|
|
61
|
+
export const markNotificationReadFn = createServerFn({ method: 'POST' })
|
|
62
|
+
.validator(markReadSchema)
|
|
63
|
+
.handler(async ({ data }) => {
|
|
64
|
+
const request = getRequest()
|
|
65
|
+
const session = await requireAuth(request)
|
|
66
|
+
const userId = session.user!.id!
|
|
67
|
+
await prisma.notification.updateMany({
|
|
68
|
+
where: { id: data.id, userId, readAt: null },
|
|
69
|
+
data: { readAt: new Date() },
|
|
70
|
+
})
|
|
71
|
+
return { success: true }
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export const markAllNotificationsReadFn = createServerFn({ method: 'POST' })
|
|
75
|
+
.validator(z.object({}).optional())
|
|
76
|
+
.handler(async () => {
|
|
77
|
+
const request = getRequest()
|
|
78
|
+
const session = await requireAuth(request)
|
|
79
|
+
const userId = session.user!.id!
|
|
80
|
+
const result = await prisma.notification.updateMany({
|
|
81
|
+
where: { userId, readAt: null },
|
|
82
|
+
data: { readAt: new Date() },
|
|
83
|
+
})
|
|
84
|
+
return { success: true, count: result.count }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const deleteSchema = z.object({ id: z.string().min(1) })
|
|
88
|
+
|
|
89
|
+
export const deleteNotificationFn = createServerFn({ method: 'POST' })
|
|
90
|
+
.validator(deleteSchema)
|
|
91
|
+
.handler(async ({ data }) => {
|
|
92
|
+
const request = getRequest()
|
|
93
|
+
const session = await requireAuth(request)
|
|
94
|
+
const userId = session.user!.id!
|
|
95
|
+
await prisma.notification.deleteMany({ where: { id: data.id, userId } })
|
|
96
|
+
return { success: true }
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const getUnreadCountFn = createServerFn({ method: 'GET' })
|
|
100
|
+
.validator(z.object({}).optional())
|
|
101
|
+
.handler(async () => {
|
|
102
|
+
const request = getRequest()
|
|
103
|
+
const session = await requireAuth(request)
|
|
104
|
+
const userId = session.user!.id!
|
|
105
|
+
const count = await prisma.notification.count({
|
|
106
|
+
where: { userId, readAt: null },
|
|
107
|
+
})
|
|
108
|
+
return { count }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
|
|
112
|
+
.validator(z.object({}).optional())
|
|
113
|
+
.handler(async () => {
|
|
114
|
+
const request = getRequest()
|
|
115
|
+
const session = await requireAuth(request)
|
|
116
|
+
return getOrCreatePreferences(session.user!.id!)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const preferencesSchema = z.object({
|
|
120
|
+
categories: z.record(z.string(), z.boolean()),
|
|
121
|
+
dndEnabled: z.boolean(),
|
|
122
|
+
dndStart: z.string().regex(/^\d{2}:\d{2}$/),
|
|
123
|
+
dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
export const updateNotificationPreferencesFn = createServerFn({
|
|
127
|
+
method: 'POST',
|
|
128
|
+
})
|
|
129
|
+
.validator(preferencesSchema)
|
|
130
|
+
.handler(async ({ data }) => {
|
|
131
|
+
const request = getRequest()
|
|
132
|
+
const session = await requireAuth(request)
|
|
133
|
+
const userId = session.user!.id!
|
|
134
|
+
const categories = { ...DEFAULT_CATEGORIES } as CategoryPreferences
|
|
135
|
+
for (const c of NOTIFICATION_CATEGORIES) {
|
|
136
|
+
if (typeof data.categories[c] === 'boolean') categories[c] = data.categories[c]
|
|
137
|
+
}
|
|
138
|
+
await prisma.notificationPreference.upsert({
|
|
139
|
+
where: { userId },
|
|
140
|
+
create: {
|
|
141
|
+
userId,
|
|
142
|
+
categories,
|
|
143
|
+
dndEnabled: data.dndEnabled,
|
|
144
|
+
dndStart: data.dndStart,
|
|
145
|
+
dndEnd: data.dndEnd,
|
|
146
|
+
},
|
|
147
|
+
update: {
|
|
148
|
+
categories,
|
|
149
|
+
dndEnabled: data.dndEnabled,
|
|
150
|
+
dndStart: data.dndStart,
|
|
151
|
+
dndEnd: data.dndEnd,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
return { success: true }
|
|
155
|
+
})
|