@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServerFn } from '@tanstack/react-start'
|
|
2
2
|
import { getRequest } from '@tanstack/react-start/server'
|
|
3
|
-
import
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
4
|
import { z } from 'zod'
|
|
5
5
|
import { prisma } from '@allbluecn/database'
|
|
6
6
|
import { requireAuth } from '@/server/auth-helpers'
|
|
@@ -10,12 +10,21 @@ import { getEmailSender } from '@/server/email/sender'
|
|
|
10
10
|
import { USER_BRIEF_SELECT } from '@/server/team-access'
|
|
11
11
|
|
|
12
12
|
const INVITE_EXPIRES_DAYS = 7
|
|
13
|
-
const OWNER_SELECT = { id: true, username: true, email: true } as const
|
|
13
|
+
const OWNER_SELECT = { id: true, username: true, email: true, avatarConfig: true, bgShape: true, bgColor: true } as const
|
|
14
|
+
const INVITE_TOKEN_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
15
|
+
|
|
16
|
+
function generateInviteToken(length = 6): string {
|
|
17
|
+
const bytes = Buffer.allocUnsafe(length)
|
|
18
|
+
crypto.randomFillSync(bytes)
|
|
19
|
+
return Array.from(bytes).map((b) => INVITE_TOKEN_CHARS[b % INVITE_TOKEN_CHARS.length]).join('')
|
|
20
|
+
}
|
|
14
21
|
|
|
15
22
|
const createInvitationSchema = z.object({
|
|
16
23
|
email: z.string().email().optional(),
|
|
17
24
|
seatType: z.enum(['member', 'guest']),
|
|
18
25
|
channel: z.enum(['email', 'link']).default('email'),
|
|
26
|
+
expiresInDays: z.number().int().min(1).max(30).optional(),
|
|
27
|
+
message: z.string().max(500).optional(),
|
|
19
28
|
})
|
|
20
29
|
|
|
21
30
|
export const createTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
@@ -25,7 +34,7 @@ export const createTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
25
34
|
const session = await requireAuth(request)
|
|
26
35
|
const ownerId = session.user!.id!
|
|
27
36
|
|
|
28
|
-
const email = data.channel === '
|
|
37
|
+
const email = data.channel === 'email' ? data.email!.toLowerCase() : null
|
|
29
38
|
|
|
30
39
|
if (data.channel === 'email') {
|
|
31
40
|
const owner = await prisma.user.findUnique({
|
|
@@ -36,7 +45,7 @@ export const createTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
36
45
|
throw new Error('不能邀请自己')
|
|
37
46
|
}
|
|
38
47
|
const targetUser = await prisma.user.findUnique({
|
|
39
|
-
where: { email },
|
|
48
|
+
where: { email: email! },
|
|
40
49
|
select: { id: true },
|
|
41
50
|
})
|
|
42
51
|
if (targetUser) {
|
|
@@ -54,17 +63,14 @@ export const createTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
54
63
|
})
|
|
55
64
|
if (pendingAnywhere) throw new Error('该邮箱已有待处理的团队邀请')
|
|
56
65
|
} else {
|
|
57
|
-
// link 邀请不绑定邮箱(email=''
|
|
58
|
-
const pendingLink = await prisma.teamInvitation.findFirst({
|
|
59
|
-
where: { ownerId, channel: 'link', status: 'pending' },
|
|
60
|
-
})
|
|
61
|
-
if (pendingLink) throw new Error('已存在待使用的邀请链接,请先撤销或等待接受')
|
|
66
|
+
// link 邀请不绑定邮箱(email=''),有效期内不限次数申请,无需检查已有 pending 链接
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
await requireTeamSeats(ownerId, data.seatType, 1)
|
|
65
70
|
|
|
66
|
-
const token =
|
|
67
|
-
const
|
|
71
|
+
const token = generateInviteToken()
|
|
72
|
+
const expiresInDays = data.expiresInDays ?? INVITE_EXPIRES_DAYS
|
|
73
|
+
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
|
68
74
|
|
|
69
75
|
let invitation
|
|
70
76
|
try {
|
|
@@ -77,6 +83,7 @@ export const createTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
77
83
|
token,
|
|
78
84
|
status: 'pending',
|
|
79
85
|
expiresAt,
|
|
86
|
+
message: data.message,
|
|
80
87
|
},
|
|
81
88
|
})
|
|
82
89
|
} catch (err) {
|
|
@@ -114,23 +121,81 @@ export const createTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
114
121
|
})
|
|
115
122
|
|
|
116
123
|
export const listTeamInvitationsFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
try {
|
|
125
|
+
const request = getRequest()
|
|
126
|
+
const session = await requireAuth(request)
|
|
127
|
+
const ownerId = session.user!.id!
|
|
128
|
+
const now = new Date()
|
|
129
|
+
|
|
130
|
+
await prisma.teamInvitation.updateMany({
|
|
131
|
+
where: {
|
|
132
|
+
ownerId,
|
|
133
|
+
status: { in: ['pending', 'pending_approval'] },
|
|
134
|
+
expiresAt: { lt: now },
|
|
135
|
+
},
|
|
136
|
+
data: { status: 'expired' },
|
|
137
|
+
})
|
|
138
|
+
await prisma.teamJoinRequest.updateMany({
|
|
139
|
+
where: {
|
|
140
|
+
ownerId,
|
|
141
|
+
status: 'pending',
|
|
142
|
+
invitation: { channel: 'link', status: { in: ['expired', 'revoked'] } },
|
|
143
|
+
},
|
|
144
|
+
data: { status: 'invalidated', processedAt: now },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const [invitations, joinRequests] = await Promise.all([
|
|
148
|
+
prisma.teamInvitation.findMany({
|
|
149
|
+
where: {
|
|
150
|
+
ownerId,
|
|
151
|
+
OR: [
|
|
152
|
+
{ status: { in: ['pending', 'pending_approval', 'accepted', 'expired', 'revoked'] } },
|
|
153
|
+
{ channel: 'link' },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
orderBy: { createdAt: 'desc' },
|
|
157
|
+
include: { owner: { select: OWNER_SELECT } },
|
|
158
|
+
}),
|
|
159
|
+
prisma.teamJoinRequest.findMany({
|
|
160
|
+
where: { ownerId },
|
|
161
|
+
orderBy: { createdAt: 'desc' },
|
|
162
|
+
take: 50,
|
|
163
|
+
select: {
|
|
164
|
+
id: true, email: true, userId: true, status: true, seatType: true,
|
|
165
|
+
createdAt: true, processedAt: true,
|
|
166
|
+
applicant: { select: USER_BRIEF_SELECT },
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
])
|
|
170
|
+
return { invitations, joinRequests }
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error('[listTeamInvitationsFn] ERROR:', error instanceof Error ? error.message : 'unknown')
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
130
175
|
})
|
|
131
176
|
|
|
132
177
|
const revokeSchema = z.object({ invitationId: z.string() })
|
|
133
178
|
|
|
179
|
+
export const clearExpiredInvitationsFn = createServerFn({ method: 'POST' })
|
|
180
|
+
.validator(z.object({}))
|
|
181
|
+
.handler(async () => {
|
|
182
|
+
const request = getRequest()
|
|
183
|
+
const session = await requireAuth(request)
|
|
184
|
+
const ownerId = session.user!.id!
|
|
185
|
+
const res = await prisma.teamInvitation.deleteMany({
|
|
186
|
+
where: { ownerId, status: 'expired' },
|
|
187
|
+
})
|
|
188
|
+
await auditLog({
|
|
189
|
+
workspaceId: '',
|
|
190
|
+
userId: ownerId,
|
|
191
|
+
resourceType: 'teamInvitation',
|
|
192
|
+
resourceId: '',
|
|
193
|
+
action: 'team.invite.clear-expired',
|
|
194
|
+
metadata: { count: res.count },
|
|
195
|
+
})
|
|
196
|
+
return { success: true, count: res.count }
|
|
197
|
+
})
|
|
198
|
+
|
|
134
199
|
export const revokeTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
135
200
|
.validator(revokeSchema)
|
|
136
201
|
.handler(async ({ data }) => {
|
|
@@ -139,7 +204,13 @@ export const revokeTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
139
204
|
const ownerId = session.user!.id!
|
|
140
205
|
const inv = await prisma.teamInvitation.findUnique({ where: { id: data.invitationId } })
|
|
141
206
|
if (!inv || inv.ownerId !== ownerId) throw new Error('邀请不存在')
|
|
142
|
-
if (inv.status !== 'pending') throw new Error('
|
|
207
|
+
if (inv.status !== 'pending' && inv.status !== 'pending_approval') throw new Error('仅待处理/待批准邀请可撤销')
|
|
208
|
+
if (inv.channel === 'link') {
|
|
209
|
+
await prisma.teamJoinRequest.updateMany({
|
|
210
|
+
where: { invitationId: inv.id, status: 'pending' },
|
|
211
|
+
data: { status: 'invalidated', processedAt: new Date() },
|
|
212
|
+
})
|
|
213
|
+
}
|
|
143
214
|
await prisma.teamInvitation.update({
|
|
144
215
|
where: { id: inv.id },
|
|
145
216
|
data: { status: 'revoked' },
|
|
@@ -166,7 +237,7 @@ export const resendTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
166
237
|
throw new Error('仅待处理/已过期邀请可重发')
|
|
167
238
|
}
|
|
168
239
|
await requireTeamSeats(ownerId, inv.seatType as 'member' | 'guest', 1)
|
|
169
|
-
const token =
|
|
240
|
+
const token = generateInviteToken()
|
|
170
241
|
await prisma.teamInvitation.update({
|
|
171
242
|
where: { id: inv.id },
|
|
172
243
|
data: {
|
|
@@ -187,24 +258,95 @@ export const resendTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
187
258
|
return { success: true, token }
|
|
188
259
|
})
|
|
189
260
|
|
|
261
|
+
export const refreshTeamInvitationLinkFn = createServerFn({ method: 'POST' })
|
|
262
|
+
.validator(z.object({ expiresInDays: z.number().int().min(1).max(365).optional() }))
|
|
263
|
+
.handler(async ({ data }) => {
|
|
264
|
+
const request = getRequest()
|
|
265
|
+
const session = await requireAuth(request)
|
|
266
|
+
const ownerId = session.user!.id!
|
|
267
|
+
|
|
268
|
+
await requireTeamSeats(ownerId, 'member', 1)
|
|
269
|
+
|
|
270
|
+
const expiresInDays = data.expiresInDays ?? INVITE_EXPIRES_DAYS
|
|
271
|
+
const token = generateInviteToken()
|
|
272
|
+
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
|
273
|
+
const invitation = await prisma.$transaction(async (tx) => {
|
|
274
|
+
await tx.teamJoinRequest.updateMany({
|
|
275
|
+
where: {
|
|
276
|
+
ownerId,
|
|
277
|
+
status: 'pending',
|
|
278
|
+
invitation: { channel: 'link', status: { in: ['pending', 'pending_approval'] } },
|
|
279
|
+
},
|
|
280
|
+
data: { status: 'invalidated', processedAt: new Date() },
|
|
281
|
+
})
|
|
282
|
+
await tx.teamInvitation.deleteMany({
|
|
283
|
+
where: { ownerId, channel: 'link', status: { in: ['pending', 'pending_approval'] } },
|
|
284
|
+
})
|
|
285
|
+
return tx.teamInvitation.create({
|
|
286
|
+
data: {
|
|
287
|
+
ownerId,
|
|
288
|
+
email: null,
|
|
289
|
+
seatType: 'member',
|
|
290
|
+
channel: 'link',
|
|
291
|
+
token,
|
|
292
|
+
status: 'pending',
|
|
293
|
+
expiresAt,
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
await auditLog({
|
|
299
|
+
workspaceId: '',
|
|
300
|
+
userId: ownerId,
|
|
301
|
+
resourceType: 'teamInvitation',
|
|
302
|
+
resourceId: invitation.id,
|
|
303
|
+
action: 'team.invite.refresh',
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
return { success: true, token, invitationId: invitation.id }
|
|
307
|
+
})
|
|
308
|
+
|
|
190
309
|
// ---------- 成员管理 ----------
|
|
191
310
|
|
|
192
311
|
export const listTeamMembersFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
193
312
|
const request = getRequest()
|
|
194
313
|
const session = await requireAuth(request)
|
|
195
314
|
const ownerId = session.user!.id!
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
id: true,
|
|
201
|
-
ownerId: true,
|
|
202
|
-
memberId: true,
|
|
203
|
-
seatType: true,
|
|
204
|
-
joinedAt: true,
|
|
205
|
-
member: { select: USER_BRIEF_SELECT },
|
|
206
|
-
},
|
|
315
|
+
|
|
316
|
+
// 以 memberId 判当前用户的归属:避免为已是别家成员的账号创建自引用 owner 记录(P2002)
|
|
317
|
+
const ownEntry = await prisma.teamMembership.findUnique({
|
|
318
|
+
where: { memberId: ownerId },
|
|
207
319
|
})
|
|
320
|
+
if (!ownEntry) {
|
|
321
|
+
const membership = await prisma.teamMembership.create({
|
|
322
|
+
data: { ownerId, memberId: ownerId, seatType: 'owner' },
|
|
323
|
+
select: {
|
|
324
|
+
id: true,
|
|
325
|
+
ownerId: true,
|
|
326
|
+
memberId: true,
|
|
327
|
+
seatType: true,
|
|
328
|
+
joinedAt: true,
|
|
329
|
+
member: { select: USER_BRIEF_SELECT },
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
return [membership]
|
|
333
|
+
}
|
|
334
|
+
if (ownEntry.ownerId === ownerId) {
|
|
335
|
+
return prisma.teamMembership.findMany({
|
|
336
|
+
where: { ownerId },
|
|
337
|
+
orderBy: { joinedAt: 'asc' },
|
|
338
|
+
select: {
|
|
339
|
+
id: true,
|
|
340
|
+
ownerId: true,
|
|
341
|
+
memberId: true,
|
|
342
|
+
seatType: true,
|
|
343
|
+
joinedAt: true,
|
|
344
|
+
member: { select: USER_BRIEF_SELECT },
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
// 当前用户是别家团队成员,非本团队 owner → 返回空(成员视图由 teamUsers 兜底)
|
|
349
|
+
return []
|
|
208
350
|
})
|
|
209
351
|
|
|
210
352
|
const removeMemberSchema = z.object({ membershipId: z.string() })
|
|
@@ -249,9 +391,108 @@ export const leaveTeamFn = createServerFn({ method: 'POST' }).validator(z.object
|
|
|
249
391
|
resourceId: m.id,
|
|
250
392
|
action: 'team.member.leave',
|
|
251
393
|
})
|
|
252
|
-
|
|
394
|
+
return { success: true }
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
const joinRequestSchema = z.object({ requestId: z.string() })
|
|
398
|
+
|
|
399
|
+
const approveJoinRequestSchema = z.object({
|
|
400
|
+
requestId: z.string(),
|
|
401
|
+
seatType: z.enum(['member', 'guest']),
|
|
253
402
|
})
|
|
254
403
|
|
|
404
|
+
export const approveTeamJoinRequestFn = createServerFn({ method: 'POST' })
|
|
405
|
+
.validator(approveJoinRequestSchema)
|
|
406
|
+
.handler(async ({ data }) => {
|
|
407
|
+
const request = getRequest()
|
|
408
|
+
const session = await requireAuth(request)
|
|
409
|
+
const ownerId = session.user!.id!
|
|
410
|
+
|
|
411
|
+
const reqRow = await prisma.teamJoinRequest.findUnique({
|
|
412
|
+
where: { id: data.requestId },
|
|
413
|
+
include: { invitation: { select: { id: true, status: true, channel: true, expiresAt: true } } },
|
|
414
|
+
})
|
|
415
|
+
if (!reqRow || reqRow.ownerId !== ownerId) throw new Error('申请不存在')
|
|
416
|
+
if (reqRow.status !== 'pending') throw new Error('仅待审批申请可操作')
|
|
417
|
+
|
|
418
|
+
if (reqRow.invitation.status !== 'pending' || reqRow.invitation.expiresAt < new Date()) {
|
|
419
|
+
await prisma.teamJoinRequest.update({
|
|
420
|
+
where: { id: reqRow.id },
|
|
421
|
+
data: { status: 'invalidated', processedAt: new Date() },
|
|
422
|
+
})
|
|
423
|
+
throw new Error('邀请链接已过期或失效,无法批准')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let applicantUserId = reqRow.userId
|
|
427
|
+
if (!applicantUserId) {
|
|
428
|
+
const applicant = await prisma.user.findUnique({
|
|
429
|
+
where: { email: reqRow.email },
|
|
430
|
+
select: { id: true },
|
|
431
|
+
})
|
|
432
|
+
applicantUserId = applicant?.id ?? null
|
|
433
|
+
}
|
|
434
|
+
if (!applicantUserId) throw new Error('申请人尚未注册,无法批准')
|
|
435
|
+
|
|
436
|
+
const membership = await prisma.teamMembership.findUnique({
|
|
437
|
+
where: { memberId: applicantUserId },
|
|
438
|
+
})
|
|
439
|
+
if (membership) throw new Error('该用户已加入团队,无法批准')
|
|
440
|
+
|
|
441
|
+
await requireTeamSeats(ownerId, data.seatType, 1)
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
await prisma.$transaction(async (tx) => {
|
|
445
|
+
await tx.teamMembership.create({
|
|
446
|
+
data: { ownerId, memberId: applicantUserId, seatType: data.seatType },
|
|
447
|
+
})
|
|
448
|
+
await tx.teamJoinRequest.update({
|
|
449
|
+
where: { id: reqRow.id },
|
|
450
|
+
data: { status: 'approved', seatType: data.seatType, processedAt: new Date() },
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if ((err as { code?: string })?.code === 'P2002') throw new Error('该用户已加入团队')
|
|
455
|
+
throw err
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await auditLog({
|
|
459
|
+
workspaceId: '',
|
|
460
|
+
userId: ownerId,
|
|
461
|
+
resourceType: 'teamJoinRequest',
|
|
462
|
+
resourceId: reqRow.id,
|
|
463
|
+
action: 'team.join.approve',
|
|
464
|
+
metadata: { seatType: data.seatType, email: reqRow.email },
|
|
465
|
+
})
|
|
466
|
+
return { success: true }
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
export const rejectTeamJoinRequestFn = createServerFn({ method: 'POST' })
|
|
470
|
+
.validator(joinRequestSchema)
|
|
471
|
+
.handler(async ({ data }) => {
|
|
472
|
+
const request = getRequest()
|
|
473
|
+
const session = await requireAuth(request)
|
|
474
|
+
const ownerId = session.user!.id!
|
|
475
|
+
|
|
476
|
+
const reqRow = await prisma.teamJoinRequest.findUnique({ where: { id: data.requestId } })
|
|
477
|
+
if (!reqRow || reqRow.ownerId !== ownerId) throw new Error('申请不存在')
|
|
478
|
+
if (reqRow.status !== 'pending') throw new Error('仅待审批申请可操作')
|
|
479
|
+
|
|
480
|
+
await prisma.teamJoinRequest.update({
|
|
481
|
+
where: { id: reqRow.id },
|
|
482
|
+
data: { status: 'rejected', processedAt: new Date() },
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
await auditLog({
|
|
486
|
+
workspaceId: '',
|
|
487
|
+
userId: ownerId,
|
|
488
|
+
resourceType: 'teamJoinRequest',
|
|
489
|
+
resourceId: reqRow.id,
|
|
490
|
+
action: 'team.join.reject',
|
|
491
|
+
metadata: { email: reqRow.email },
|
|
492
|
+
})
|
|
493
|
+
return { success: true }
|
|
494
|
+
})
|
|
495
|
+
|
|
255
496
|
export const listTeamUsersFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
256
497
|
const request = getRequest()
|
|
257
498
|
const session = await requireAuth(request)
|
|
@@ -272,18 +513,167 @@ export const listTeamUsersFn = createServerFn({ method: 'GET' }).handler(async (
|
|
|
272
513
|
|
|
273
514
|
// ---------- 邀请接受 ----------
|
|
274
515
|
|
|
275
|
-
const tokenSchema = z.object({
|
|
516
|
+
const tokenSchema = z.object({
|
|
517
|
+
token: z.string(),
|
|
518
|
+
userId: z.string().nullable().optional(),
|
|
519
|
+
})
|
|
276
520
|
|
|
277
521
|
export const getTeamInvitationByTokenFn = createServerFn({ method: 'GET' })
|
|
278
522
|
.validator(tokenSchema)
|
|
279
523
|
.handler(async ({ data }) => {
|
|
280
524
|
const inv = await prisma.teamInvitation.findUnique({
|
|
281
525
|
where: { token: data.token },
|
|
282
|
-
select: { status: true, expiresAt: true, seatType: true, channel: true, email: true, owner: { select: OWNER_SELECT } },
|
|
526
|
+
select: { status: true, expiresAt: true, seatType: true, channel: true, email: true, id: true, ownerId: true, owner: { select: OWNER_SELECT }, acceptedBy: true, applicantEmail: true },
|
|
283
527
|
})
|
|
284
|
-
if (!inv)
|
|
285
|
-
|
|
286
|
-
|
|
528
|
+
if (!inv) return null
|
|
529
|
+
|
|
530
|
+
let effectiveStatus = inv.status
|
|
531
|
+
if (
|
|
532
|
+
(inv.status === 'pending' || inv.status === 'pending_approval') &&
|
|
533
|
+
inv.expiresAt < new Date()
|
|
534
|
+
) {
|
|
535
|
+
try {
|
|
536
|
+
await prisma.teamInvitation.update({ where: { id: inv.id }, data: { status: 'expired' } })
|
|
537
|
+
await prisma.teamJoinRequest.updateMany({
|
|
538
|
+
where: { invitationId: inv.id, status: 'pending' },
|
|
539
|
+
data: { status: 'invalidated', processedAt: new Date() },
|
|
540
|
+
})
|
|
541
|
+
} catch { /* ignore */ }
|
|
542
|
+
effectiveStatus = 'expired'
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (
|
|
546
|
+
data.userId &&
|
|
547
|
+
inv.channel === 'email' &&
|
|
548
|
+
effectiveStatus === 'pending_approval' &&
|
|
549
|
+
inv.acceptedBy === data.userId
|
|
550
|
+
) {
|
|
551
|
+
const membership = await prisma.teamMembership.findUnique({
|
|
552
|
+
where: { memberId: data.userId },
|
|
553
|
+
})
|
|
554
|
+
if (!membership || membership.ownerId !== inv.ownerId) {
|
|
555
|
+
try {
|
|
556
|
+
await prisma.teamInvitation.update({ where: { id: inv.id }, data: { status: 'pending' } })
|
|
557
|
+
effectiveStatus = 'pending'
|
|
558
|
+
} catch { /* ignore */ }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let alreadyJoined = false
|
|
563
|
+
let joinedOwnerId: string | null = null
|
|
564
|
+
let myRequestStatus: string | null = null
|
|
565
|
+
if (data.userId) {
|
|
566
|
+
const membership = await prisma.teamMembership.findUnique({
|
|
567
|
+
where: { memberId: data.userId },
|
|
568
|
+
})
|
|
569
|
+
if (membership) {
|
|
570
|
+
alreadyJoined = true
|
|
571
|
+
joinedOwnerId = membership.ownerId
|
|
572
|
+
}
|
|
573
|
+
if (inv.channel === 'link' && !alreadyJoined) {
|
|
574
|
+
const me = await prisma.user.findUnique({
|
|
575
|
+
where: { id: data.userId },
|
|
576
|
+
select: { email: true },
|
|
577
|
+
})
|
|
578
|
+
if (me?.email) {
|
|
579
|
+
const reqRow = await prisma.teamJoinRequest.findUnique({
|
|
580
|
+
where: { invitationId_email: { invitationId: inv.id, email: me.email.toLowerCase() } },
|
|
581
|
+
})
|
|
582
|
+
myRequestStatus = reqRow?.status ?? null
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
...inv,
|
|
589
|
+
effectiveStatus,
|
|
590
|
+
selectedRole: inv.seatType as 'member' | 'guest',
|
|
591
|
+
alreadyJoined,
|
|
592
|
+
joinedOwnerId,
|
|
593
|
+
myRequestStatus,
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
export const checkInviteEmailRegisteredFn = createServerFn({ method: 'POST' })
|
|
598
|
+
.validator(z.object({ email: z.string().email() }))
|
|
599
|
+
.handler(async ({ data }) => {
|
|
600
|
+
const user = await prisma.user.findUnique({
|
|
601
|
+
where: { email: data.email.toLowerCase() },
|
|
602
|
+
select: { id: true },
|
|
603
|
+
})
|
|
604
|
+
return { registered: !!user }
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
export const applyTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
608
|
+
.validator(z.object({ token: z.string(), email: z.string().email() }))
|
|
609
|
+
.handler(async ({ data }) => {
|
|
610
|
+
const inv = await prisma.teamInvitation.findUnique({
|
|
611
|
+
where: { token: data.token },
|
|
612
|
+
include: { owner: { select: { id: true, email: true } } },
|
|
613
|
+
})
|
|
614
|
+
if (!inv) throw new Error('邀请链接不存在')
|
|
615
|
+
if (inv.status !== 'pending' && !(inv.channel === 'email' && inv.status === 'pending_approval')) {
|
|
616
|
+
throw new Error('邀请已失效')
|
|
617
|
+
}
|
|
618
|
+
if (inv.expiresAt < new Date()) throw new Error('邀请已过期,请联系团队所有者重新生成邀请链接')
|
|
619
|
+
|
|
620
|
+
const email = data.email.toLowerCase()
|
|
621
|
+
if (inv.channel === 'email' && inv.email && email !== inv.email) {
|
|
622
|
+
throw new Error('申请邮箱与邀请邮箱不一致')
|
|
623
|
+
}
|
|
624
|
+
if (email === inv.owner.email?.toLowerCase()) {
|
|
625
|
+
throw new Error('不能加入自己的团队')
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (inv.channel === 'email') {
|
|
629
|
+
const existing = await prisma.teamInvitation.findFirst({
|
|
630
|
+
where: { id: inv.id, status: 'pending_approval', applicantEmail: email },
|
|
631
|
+
})
|
|
632
|
+
if (!existing) {
|
|
633
|
+
await prisma.teamInvitation.update({
|
|
634
|
+
where: { id: inv.id },
|
|
635
|
+
data: { status: 'pending_approval', applicantEmail: email },
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
return { success: true, requiresApproval: true, duplicate: !!existing }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const existingUser = await prisma.user.findUnique({ where: { email }, select: { id: true } })
|
|
642
|
+
if (existingUser) {
|
|
643
|
+
const membership = await prisma.teamMembership.findUnique({
|
|
644
|
+
where: { memberId: existingUser.id },
|
|
645
|
+
})
|
|
646
|
+
if (membership) throw new Error('该账号已加入团队(每账号仅可加入一个团队)')
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const existingRequest = await prisma.teamJoinRequest.findUnique({
|
|
650
|
+
where: { invitationId_email: { invitationId: inv.id, email } },
|
|
651
|
+
})
|
|
652
|
+
if (existingRequest?.status === 'pending') {
|
|
653
|
+
return { success: true, requiresApproval: true, duplicate: true }
|
|
654
|
+
}
|
|
655
|
+
await prisma.teamJoinRequest.upsert({
|
|
656
|
+
where: { invitationId_email: { invitationId: inv.id, email } },
|
|
657
|
+
create: {
|
|
658
|
+
invitationId: inv.id,
|
|
659
|
+
ownerId: inv.ownerId,
|
|
660
|
+
email,
|
|
661
|
+
userId: existingUser?.id ?? null,
|
|
662
|
+
status: 'pending',
|
|
663
|
+
},
|
|
664
|
+
update: { status: 'pending', userId: existingUser?.id ?? null, processedAt: null },
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
await auditLog({
|
|
668
|
+
workspaceId: '',
|
|
669
|
+
userId: '',
|
|
670
|
+
resourceType: 'teamJoinRequest',
|
|
671
|
+
resourceId: inv.id,
|
|
672
|
+
action: 'team.join.apply',
|
|
673
|
+
metadata: { applicantEmail: email },
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
return { success: true, requiresApproval: true, duplicate: false }
|
|
287
677
|
})
|
|
288
678
|
|
|
289
679
|
export const acceptTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
@@ -292,39 +682,117 @@ export const acceptTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
|
292
682
|
const request = getRequest()
|
|
293
683
|
const session = await requireAuth(request)
|
|
294
684
|
const userId = session.user!.id!
|
|
295
|
-
const
|
|
685
|
+
const currentUser = await prisma.user.findUnique({
|
|
686
|
+
where: { id: userId },
|
|
687
|
+
select: { email: true },
|
|
688
|
+
})
|
|
689
|
+
const email = (currentUser?.email ?? session.user!.email ?? '').toLowerCase()
|
|
690
|
+
if (!email) throw new Error('当前账号缺少邮箱,无法申请')
|
|
296
691
|
|
|
297
692
|
const inv = await prisma.teamInvitation.findUnique({ where: { token: data.token } })
|
|
298
693
|
if (!inv) throw new Error('邀请不存在')
|
|
299
694
|
if (inv.status !== 'pending') throw new Error('邀请已失效')
|
|
300
|
-
if (inv.expiresAt < new Date()) throw new Error('
|
|
301
|
-
if (inv.ownerId === userId) throw new Error('
|
|
695
|
+
if (inv.expiresAt < new Date()) throw new Error('邀请已过期,请联系团队所有者重新生成邀请链接')
|
|
696
|
+
if (inv.ownerId === userId) throw new Error('不能加入自己的团队')
|
|
302
697
|
|
|
303
698
|
const joined = await prisma.teamMembership.findUnique({ where: { memberId: userId } })
|
|
304
699
|
if (joined) throw new Error('你已加入团队(每账号仅可加入一个团队)')
|
|
305
700
|
|
|
306
|
-
if (inv.channel === 'email'
|
|
307
|
-
|
|
701
|
+
if (inv.channel === 'email') {
|
|
702
|
+
if (inv.email && email !== inv.email) {
|
|
703
|
+
throw new Error('邀请邮箱与当前账号邮箱不一致')
|
|
704
|
+
}
|
|
705
|
+
await prisma.teamInvitation.update({
|
|
706
|
+
where: { id: inv.id },
|
|
707
|
+
data: { status: 'pending_approval', applicantEmail: email, acceptedBy: userId },
|
|
708
|
+
})
|
|
709
|
+
await auditLog({
|
|
710
|
+
workspaceId: '',
|
|
711
|
+
userId,
|
|
712
|
+
resourceType: 'teamInvitation',
|
|
713
|
+
resourceId: inv.id,
|
|
714
|
+
action: 'team.invite.accept',
|
|
715
|
+
})
|
|
716
|
+
return { success: true, requiresApproval: true }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const existingRequest = await prisma.teamJoinRequest.findUnique({
|
|
720
|
+
where: { invitationId_email: { invitationId: inv.id, email } },
|
|
721
|
+
})
|
|
722
|
+
if (existingRequest?.status === 'pending') {
|
|
723
|
+
return { success: true, requiresApproval: true, duplicate: true }
|
|
724
|
+
}
|
|
725
|
+
await prisma.teamJoinRequest.upsert({
|
|
726
|
+
where: { invitationId_email: { invitationId: inv.id, email } },
|
|
727
|
+
create: { invitationId: inv.id, ownerId: inv.ownerId, email, userId, status: 'pending' },
|
|
728
|
+
update: { status: 'pending', userId, processedAt: null },
|
|
729
|
+
})
|
|
730
|
+
await auditLog({
|
|
731
|
+
workspaceId: '',
|
|
732
|
+
userId,
|
|
733
|
+
resourceType: 'teamJoinRequest',
|
|
734
|
+
resourceId: inv.id,
|
|
735
|
+
action: 'team.join.apply',
|
|
736
|
+
metadata: { applicantEmail: email },
|
|
737
|
+
})
|
|
738
|
+
return { success: true, requiresApproval: true, duplicate: false }
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
const approveSchema = z.object({
|
|
742
|
+
invitationId: z.string(),
|
|
743
|
+
seatType: z.enum(['member', 'guest']),
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
export const approveTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
747
|
+
.validator(approveSchema)
|
|
748
|
+
.handler(async ({ data }) => {
|
|
749
|
+
const request = getRequest()
|
|
750
|
+
const session = await requireAuth(request)
|
|
751
|
+
const ownerId = session.user!.id!
|
|
752
|
+
|
|
753
|
+
const inv = await prisma.teamInvitation.findUnique({
|
|
754
|
+
where: { id: data.invitationId },
|
|
755
|
+
})
|
|
756
|
+
if (!inv || inv.ownerId !== ownerId) throw new Error('邀请不存在')
|
|
757
|
+
if (inv.status !== 'pending_approval') throw new Error('仅待批准邀请可操作')
|
|
758
|
+
if (inv.expiresAt < new Date()) throw new Error('邀请已过期,无法批准')
|
|
759
|
+
if (inv.channel !== 'email') throw new Error('链接渠道申请请使用 approveTeamJoinRequestFn')
|
|
760
|
+
|
|
761
|
+
// 批准前复查席位
|
|
762
|
+
await requireTeamSeats(ownerId, data.seatType, 1, { excludeInvitationId: inv.id })
|
|
763
|
+
|
|
764
|
+
let applicantUserId = inv.acceptedBy
|
|
765
|
+
if (!applicantUserId && inv.applicantEmail) {
|
|
766
|
+
const applicantUser = await prisma.user.findUnique({
|
|
767
|
+
where: { email: inv.applicantEmail.toLowerCase() },
|
|
768
|
+
select: { id: true },
|
|
769
|
+
})
|
|
770
|
+
applicantUserId = applicantUser?.id ?? null
|
|
308
771
|
}
|
|
772
|
+
if (!applicantUserId) throw new Error('无法确定申请人身份,请让申请人重新申请')
|
|
309
773
|
|
|
310
774
|
try {
|
|
311
775
|
await prisma.$transaction(async (tx) => {
|
|
312
|
-
await tx.teamInvitation.update({ where: { id: inv.id }, data: { status: 'accepted', acceptedBy: userId } })
|
|
313
776
|
await tx.teamMembership.create({
|
|
314
|
-
data: { ownerId
|
|
777
|
+
data: { ownerId, memberId: applicantUserId, seatType: data.seatType },
|
|
778
|
+
})
|
|
779
|
+
await tx.teamInvitation.update({
|
|
780
|
+
where: { id: inv.id },
|
|
781
|
+
data: { seatType: data.seatType, status: 'accepted' },
|
|
315
782
|
})
|
|
316
783
|
})
|
|
317
784
|
} catch (err) {
|
|
318
|
-
if ((err as any)?.code === 'P2002') throw new Error('
|
|
785
|
+
if ((err as any)?.code === 'P2002') throw new Error('该用户已加入团队')
|
|
319
786
|
throw err
|
|
320
787
|
}
|
|
321
788
|
|
|
322
789
|
await auditLog({
|
|
323
790
|
workspaceId: '',
|
|
324
|
-
userId,
|
|
791
|
+
userId: ownerId,
|
|
325
792
|
resourceType: 'teamInvitation',
|
|
326
793
|
resourceId: inv.id,
|
|
327
|
-
action: 'team.invite.
|
|
794
|
+
action: 'team.invite.approve',
|
|
795
|
+
metadata: { seatType: data.seatType },
|
|
328
796
|
})
|
|
329
797
|
return { success: true }
|
|
330
798
|
})
|