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