@allbluecn/web-app 0.4.16 → 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.
Files changed (78) hide show
  1. package/package.json +10 -10
  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/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
  15. package/src/components/story/display/display-dropdown.tsx +6 -7
  16. package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
  17. package/src/components/story/story-list-view.tsx +13 -2
  18. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  19. package/src/hooks/use-notifications.ts +119 -0
  20. package/src/lib/app-version.ts +4 -1
  21. package/src/lib/auth/auth.config.ts +17 -1
  22. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  23. package/src/lib/changelog/sdk-versions.ts +29 -19
  24. package/src/lib/collections/ai/index.ts +0 -1
  25. package/src/lib/collections/index.ts +0 -1
  26. package/src/plugins/.gen-manifest.json +13 -0
  27. package/src/plugins/modules.gen.ts +20 -0
  28. package/src/plugins/plugins.gen.ts +20 -0
  29. package/src/plugins/ui.gen.ts +10 -0
  30. package/src/routeTree.gen.ts +70 -0
  31. package/src/routes/_login/login/route.tsx +10 -1
  32. package/src/routes/_login/register/route.lazy.tsx +3 -2
  33. package/src/routes/_login/register/route.tsx +5 -0
  34. package/src/routes/_nav/inspiration/route.tsx +8 -0
  35. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  36. package/src/routes/_nav/notifications/route.tsx +8 -0
  37. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  38. package/src/routes/_nav/prd/index.tsx +9 -0
  39. package/src/routes/_nav/prd/route.tsx +8 -0
  40. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  41. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  43. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  44. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  45. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  46. package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
  47. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  48. package/src/routes/_nav/tags/index.tsx +8 -0
  49. package/src/routes/_nav/tags/route.tsx +8 -0
  50. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  51. package/src/routes/api/notifications/stream.ts +88 -0
  52. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  53. package/src/routes/invite/$token/route.tsx +16 -8
  54. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  55. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  56. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  57. package/src/server/notifications/event-bus.ts +16 -0
  58. package/src/server/notifications/notification-service.ts +86 -0
  59. package/src/server/resources.ts +10 -2
  60. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  61. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  62. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  63. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  64. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  65. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  66. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  67. package/src/server/serverFns/billing.ts +23 -2
  68. package/src/server/serverFns/notifications.ts +155 -0
  69. package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
  70. package/src/server/serverFns/story/story-comments.ts +42 -0
  71. package/src/server/serverFns/story/story-crud/labels.ts +45 -6
  72. package/src/server/serverFns/story/story-crud.ts +8 -2
  73. package/src/server/serverFns/story/story-groups.ts +26 -7
  74. package/src/server/serverFns/story/story-search.ts +4 -0
  75. package/src/server/serverFns/story-display-options.ts +15 -4
  76. package/src/server/serverFns/team.ts +573 -53
  77. package/src/start.ts +23 -1
  78. 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'
@@ -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 === 'link' ? (data.email?.toLowerCase() ?? '') : data.email!.toLowerCase()
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=''),同一 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('已存在待使用的邀请链接,请先撤销或等待接受')
67
+ // link 邀请不绑定邮箱(email=''),有效期内不限次数申请,无需检查已有 pending 链接
62
68
  }
63
69
 
64
70
  await requireTeamSeats(ownerId, data.seatType, 1)
65
71
 
66
- const token = randomUUID()
67
- const expiresAt = new Date(Date.now() + INVITE_EXPIRES_DAYS * 24 * 60 * 60 * 1000)
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
- 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
- })
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 = randomUUID()
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
- 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
- },
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
- return { success: true }
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({ token: z.string() })
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) throw new Error('邀请不存在')
285
- const expired = inv.status === 'pending' && inv.expiresAt < new Date()
286
- return { ...inv, effectiveStatus: expired ? 'expired' : inv.status }
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 email = (session.user!.email ?? '').toLowerCase()
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' && inv.email && email !== inv.email) {
307
- throw new Error('邀请邮箱与当前账号邮箱不一致')
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: inv.ownerId, memberId: userId, seatType: inv.seatType },
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.accept',
846
+ action: 'team.invite.approve',
847
+ metadata: { seatType: data.seatType },
328
848
  })
329
849
  return { success: true }
330
850
  })