@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.
Files changed (56) hide show
  1. package/package.json +11 -11
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  5. package/src/components/notifications/notification-list.tsx +232 -0
  6. package/src/components/notifications/notification-preferences.tsx +156 -0
  7. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  8. package/src/components/settings/ai/provider-grid.tsx +46 -25
  9. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  10. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  11. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  12. package/src/config/sidebar-routes.tsx +16 -0
  13. package/src/hooks/use-notifications.ts +119 -0
  14. package/src/lib/app-version.ts +4 -1
  15. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  16. package/src/lib/changelog/sdk-versions.ts +29 -19
  17. package/src/lib/collections/ai/index.ts +0 -1
  18. package/src/lib/collections/index.ts +0 -1
  19. package/src/plugins/.gen-manifest.json +13 -0
  20. package/src/plugins/modules.gen.ts +20 -0
  21. package/src/plugins/plugins.gen.ts +20 -0
  22. package/src/plugins/ui.gen.ts +8 -0
  23. package/src/routeTree.gen.ts +47 -0
  24. package/src/routes/_login/login/route.tsx +10 -1
  25. package/src/routes/_login/register/route.lazy.tsx +3 -2
  26. package/src/routes/_login/register/route.tsx +5 -0
  27. package/src/routes/_nav/inspiration/route.tsx +8 -0
  28. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  29. package/src/routes/_nav/prd/index.tsx +9 -0
  30. package/src/routes/_nav/prd/route.tsx +8 -0
  31. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  32. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  33. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  34. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  35. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  36. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  37. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  38. package/src/routes/_nav/tags/index.tsx +8 -0
  39. package/src/routes/_nav/tags/route.tsx +8 -0
  40. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  41. package/src/routes/api/notifications/stream.ts +88 -0
  42. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  43. package/src/routes/invite/$token/route.tsx +16 -8
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/notifications/event-bus.ts +16 -0
  46. package/src/server/notifications/notification-service.ts +86 -0
  47. package/src/server/resources.ts +10 -2
  48. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  49. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  50. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  51. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  52. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  53. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  54. package/src/server/serverFns/notifications.ts +155 -0
  55. package/src/server/serverFns/team.ts +521 -53
  56. 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 { randomUUID } from 'node:crypto'
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 === 'link' ? (data.email?.toLowerCase() ?? '') : data.email!.toLowerCase()
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=''),同一 owner 同一时刻仅允许一个 pending 链接(@@unique([ownerId, 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 = randomUUID()
67
- const expiresAt = new Date(Date.now() + INVITE_EXPIRES_DAYS * 24 * 60 * 60 * 1000)
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
- const request = getRequest()
118
- const session = await requireAuth(request)
119
- const ownerId = session.user!.id!
120
- const now = new Date()
121
- await prisma.teamInvitation.updateMany({
122
- where: { ownerId, status: 'pending', expiresAt: { lt: now } },
123
- data: { status: 'expired' },
124
- })
125
- return prisma.teamInvitation.findMany({
126
- where: { ownerId, status: { in: ['pending', 'accepted'] } },
127
- orderBy: { createdAt: 'desc' },
128
- include: { owner: { select: OWNER_SELECT } },
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 = randomUUID()
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
- return prisma.teamMembership.findMany({
197
- where: { ownerId },
198
- orderBy: { joinedAt: 'asc' },
199
- select: {
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
- return { success: true }
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({ token: z.string() })
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) throw new Error('邀请不存在')
285
- const expired = inv.status === 'pending' && inv.expiresAt < new Date()
286
- return { ...inv, effectiveStatus: expired ? 'expired' : inv.status }
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 email = (session.user!.email ?? '').toLowerCase()
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' && inv.email && email !== inv.email) {
307
- throw new Error('邀请邮箱与当前账号邮箱不一致')
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: inv.ownerId, memberId: userId, seatType: inv.seatType },
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.accept',
794
+ action: 'team.invite.approve',
795
+ metadata: { seatType: data.seatType },
328
796
  })
329
797
  return { success: true }
330
798
  })