@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
@@ -83,6 +83,7 @@ export async function requireTeamSeats(
83
83
  ownerId: string,
84
84
  seatType: SeatType,
85
85
  amount = 1,
86
+ opts?: { excludeInvitationId?: string },
86
87
  ): Promise<void> {
87
88
  const licensed = await verifyActiveLicense()
88
89
  const { limits, addonSeats } = await resolveAccountPlanLimits(ownerId)
@@ -97,7 +98,14 @@ export async function requireTeamSeats(
97
98
  if (limit === 'unlimited') return
98
99
  const [members, pending] = await Promise.all([
99
100
  prisma.teamMembership.count({ where: { ownerId, seatType } }),
100
- prisma.teamInvitation.count({ where: { ownerId, seatType, status: 'pending' } }),
101
+ prisma.teamInvitation.count({
102
+ where: {
103
+ ownerId,
104
+ seatType,
105
+ status: { in: ['pending', 'pending_approval'] },
106
+ ...(opts?.excludeInvitationId ? { id: { not: opts.excludeInvitationId } } : {}),
107
+ },
108
+ }),
101
109
  ])
102
110
  if (members + pending + amount > limit) {
103
111
  throw new ResourceLimitError(seatType === 'member' ? 'seats' : 'guestSeats', limit, members + pending)
@@ -141,7 +149,7 @@ export async function getResourceSnapshots(
141
149
  where: { ownerId: userId },
142
150
  }),
143
151
  prisma.teamInvitation.count({
144
- where: { ownerId: userId, status: 'pending' },
152
+ where: { ownerId: userId, status: { in: ['pending', 'pending_approval'] } },
145
153
  }),
146
154
  ])
147
155
 
@@ -92,8 +92,8 @@ describe('compliance serverFn 三路径权益门控', () => {
92
92
 
93
93
  it('路径 1:OSS 部署(proModule.compliance 为 null)→ FeatureUnavailableError', async () => {
94
94
  setProCompliance(null)
95
- await expect(requestMyExportFn()).rejects.toBeInstanceOf(FeatureUnavailableError)
96
- await expect(requestMyDeletionFn()).rejects.toBeInstanceOf(FeatureUnavailableError)
95
+ await expect(requestMyExportFn({ data: {} })).rejects.toBeInstanceOf(FeatureUnavailableError)
96
+ await expect(requestMyDeletionFn({ data: {} })).rejects.toBeInstanceOf(FeatureUnavailableError)
97
97
  expect(mocks.createExportJob).not.toHaveBeenCalled()
98
98
  })
99
99
 
@@ -109,8 +109,8 @@ describe('compliance serverFn 三路径权益门控', () => {
109
109
  planName: 'pro',
110
110
  features: { byokAi: true, hostedAi: true },
111
111
  })
112
- await expect(requestMyExportFn()).rejects.toBeInstanceOf(FeatureForbiddenError)
113
- await expect(requestMyDeletionFn()).rejects.toBeInstanceOf(FeatureForbiddenError)
112
+ await expect(requestMyExportFn({ data: {} })).rejects.toBeInstanceOf(FeatureForbiddenError)
113
+ await expect(requestMyDeletionFn({ data: {} })).rejects.toBeInstanceOf(FeatureForbiddenError)
114
114
  expect(mocks.createExportJob).not.toHaveBeenCalled()
115
115
  })
116
116
 
@@ -127,11 +127,11 @@ describe('compliance serverFn 三路径权益门控', () => {
127
127
  features: { compliance: true },
128
128
  })
129
129
 
130
- const exported = await requestMyExportFn()
130
+ const exported = await requestMyExportFn({ data: {} })
131
131
  expect(exported).toEqual({ id: 'job-1' })
132
132
  expect(mocks.createExportJob).toHaveBeenCalledWith('user', 'u1', 'u1')
133
133
 
134
- const deleted = await requestMyDeletionFn()
134
+ const deleted = await requestMyDeletionFn({ data: {} })
135
135
  expect(deleted).toEqual({ id: 'job-2' })
136
136
  expect(mocks.createDeleteJob).toHaveBeenCalledWith('user', 'u1', 'u1')
137
137
  })
@@ -29,8 +29,10 @@ vi.mock('@tanstack/react-start/server', () => ({
29
29
 
30
30
  vi.mock('@allbluecn/database', () => ({
31
31
  prisma: {
32
- teamInvitation: { findUnique: vi.fn() },
33
- teamMembership: { findUnique: vi.fn() },
32
+ user: { findUnique: vi.fn() },
33
+ teamInvitation: { findUnique: vi.fn(), update: vi.fn() },
34
+ teamMembership: { findUnique: vi.fn(), create: vi.fn() },
35
+ teamJoinRequest: { findUnique: vi.fn(), upsert: vi.fn() },
34
36
  $transaction: vi.fn(),
35
37
  },
36
38
  }))
@@ -43,8 +45,13 @@ vi.mock('@/server/audit', () => ({
43
45
  auditLog: vi.fn(),
44
46
  }))
45
47
 
48
+ vi.mock('@/server/resources', () => ({
49
+ requireTeamSeats: vi.fn().mockResolvedValue(undefined),
50
+ }))
51
+
46
52
  import { prisma } from '@allbluecn/database'
47
- import { acceptTeamInvitationFn, getTeamInvitationByTokenFn } from '../team'
53
+ import { requireTeamSeats } from '@/server/resources'
54
+ import { acceptTeamInvitationFn, getTeamInvitationByTokenFn, approveTeamInvitationFn } from '../team'
48
55
 
49
56
  describe('acceptTeamInvitationFn', () => {
50
57
  beforeEach(() => vi.clearAllMocks())
@@ -68,18 +75,33 @@ describe('acceptTeamInvitationFn', () => {
68
75
  .rejects.toMatchObject({ message: expect.stringContaining('已加入') })
69
76
  })
70
77
 
71
- it('link 邀请任意登录用户可接受 → 事务写入', async () => {
78
+ it('link 邀请任意登录用户申请 → 创建 TeamJoinRequest,不占用邀请行', async () => {
72
79
  vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
73
80
  id: 'inv1', token: 't1', status: 'pending', seatType: 'guest', channel: 'link',
74
- email: '', expiresAt: new Date(Date.now() + 60000), ownerId: 'owner1',
81
+ email: null, expiresAt: new Date(Date.now() + 60000), ownerId: 'owner1',
75
82
  } as never)
76
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
77
- vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn({
78
- teamInvitation: { update: vi.fn().mockResolvedValue({}) },
79
- teamMembership: { create: vi.fn().mockResolvedValue({ id: 'm1' }) },
83
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
84
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: 'b@x.com' } as never)
85
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
86
+ vi.mocked(prisma.teamJoinRequest.upsert).mockResolvedValue({ id: 'jr1' } as never)
87
+
88
+ const res = await acceptTeamInvitationFn({ data: { token: 't1', userId: null } })
89
+
90
+ expect(res).toMatchObject({ success: true, requiresApproval: true })
91
+ expect(prisma.teamJoinRequest.upsert).toHaveBeenCalledWith(expect.objectContaining({
92
+ where: { invitationId_email: { invitationId: 'inv1', email: 'b@x.com' } },
80
93
  }))
81
- const result = await acceptTeamInvitationFn({ data: { token: 't1' } })
82
- expect(result.success).toBe(true)
94
+ expect(prisma.teamInvitation.update).not.toHaveBeenCalled()
95
+ })
96
+
97
+ it('申请人是 owner 的 owner(互为成员)→ 拒绝', async () => {
98
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
99
+ id: 'inv1', token: 't1', status: 'pending', seatType: 'member', channel: 'link',
100
+ email: null, expiresAt: new Date(Date.now() + 60000), ownerId: 'owner1',
101
+ } as never)
102
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1', ownerId: 'u2' } as never)
103
+ await expect(acceptTeamInvitationFn({ data: { token: 't1', userId: null } }))
104
+ .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
83
105
  })
84
106
 
85
107
  it('email 邀请邮箱不匹配 → 拒绝', async () => {
@@ -98,6 +120,72 @@ describe('acceptTeamInvitationFn', () => {
98
120
  channel: 'email', email: 'b@x.com', owner: { id: 'o1', username: 'owner', email: 'o@x.com' },
99
121
  } as never)
100
122
  const result = await getTeamInvitationByTokenFn({ data: { token: 't1' } })
101
- expect(result.effectiveStatus).toBe('pending')
123
+ expect(result?.effectiveStatus).toBe('pending')
124
+ })
125
+ })
126
+
127
+ describe('approveTeamInvitationFn', () => {
128
+ beforeEach(() => vi.clearAllMocks())
129
+
130
+ it('批准前复查席位,并用 acceptedBy 创建 membership', async () => {
131
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
132
+ id: 'inv1', ownerId: 'u2', status: 'pending_approval', seatType: 'member',
133
+ acceptedBy: 'u2', token: 't1', channel: 'email', email: 'b@x.com',
134
+ expiresAt: new Date(Date.now() + 60000), createdAt: new Date(),
135
+ } as never)
136
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
137
+ const tx = {
138
+ teamMembership: { create: vi.fn().mockResolvedValue({ id: 'm1' }) },
139
+ teamInvitation: { update: vi.fn().mockResolvedValue({}) },
140
+ }
141
+ await fn(tx)
142
+ return tx
143
+ })
144
+ const result = await approveTeamInvitationFn({ data: { invitationId: 'inv1', seatType: 'member' } })
145
+ expect(result.success).toBe(true)
146
+ expect(requireTeamSeats).toHaveBeenCalledWith('u2', 'member', 1, { excludeInvitationId: 'inv1' })
147
+ })
148
+
149
+ it('批准后邮箱邀请置 accepted,重置链接邀请现在走 approveTeamJoinRequestFn', async () => {
150
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
151
+ id: 'inv1', ownerId: 'u2', status: 'pending_approval', seatType: 'member',
152
+ acceptedBy: 'u2', token: 't1', channel: 'link', email: null,
153
+ expiresAt: new Date(Date.now() + 60000), createdAt: new Date(),
154
+ } as never)
155
+ await expect(approveTeamInvitationFn({ data: { invitationId: 'inv1', seatType: 'member' } }))
156
+ .rejects.toMatchObject({ message: expect.stringContaining('approveTeamJoinRequestFn') })
157
+ })
158
+
159
+ it('acceptedBy 为 null 时用 applicantEmail 回填申请人', async () => {
160
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
161
+ id: 'inv1', ownerId: 'u2', status: 'pending_approval', seatType: 'member',
162
+ acceptedBy: null, applicantEmail: 'b@x.com', token: 't1', channel: 'email', email: 'b@x.com',
163
+ expiresAt: new Date(Date.now() + 60000), createdAt: new Date(),
164
+ } as never)
165
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u2' } as never)
166
+ let txRef: any
167
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
168
+ const tx = {
169
+ teamMembership: { create: vi.fn().mockResolvedValue({ id: 'm1' }) },
170
+ teamInvitation: { update: vi.fn().mockResolvedValue({}) },
171
+ }
172
+ txRef = tx
173
+ await fn(tx)
174
+ return tx
175
+ })
176
+ await approveTeamInvitationFn({ data: { invitationId: 'inv1', seatType: 'member' } })
177
+ expect(txRef.teamMembership.create).toHaveBeenCalledWith(
178
+ expect.objectContaining({ data: expect.objectContaining({ memberId: 'u2' }) }),
179
+ )
180
+ })
181
+
182
+ it('过期邀请拒绝批准', async () => {
183
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
184
+ id: 'inv1', ownerId: 'u2', status: 'pending_approval', seatType: 'member',
185
+ acceptedBy: 'u2', token: 't1', channel: 'email', email: 'b@x.com',
186
+ expiresAt: new Date(Date.now() - 1000), createdAt: new Date(),
187
+ } as never)
188
+ await expect(approveTeamInvitationFn({ data: { invitationId: 'inv1', seatType: 'member' } }))
189
+ .rejects.toMatchObject({ message: expect.stringContaining('过期') })
102
190
  })
103
191
  })
@@ -39,7 +39,10 @@ vi.mock('@allbluecn/database', () => ({
39
39
  findMany: vi.fn(),
40
40
  update: vi.fn(),
41
41
  count: vi.fn(),
42
+ deleteMany: vi.fn(),
42
43
  },
44
+ teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn() },
45
+ $transaction: vi.fn(),
43
46
  },
44
47
  }))
45
48
 
@@ -60,84 +63,151 @@ vi.mock('@/server/email/sender', () => ({
60
63
  }))
61
64
 
62
65
  import { prisma } from '@allbluecn/database'
63
- import { createTeamInvitationFn } from '../team'
66
+ import {
67
+ createTeamInvitationFn,
68
+ refreshTeamInvitationLinkFn,
69
+ listTeamInvitationsFn,
70
+ clearExpiredInvitationsFn,
71
+ } from '../team'
64
72
 
65
- describe('createTeamInvitationFn 互斥校验', () => {
73
+ describe('refreshTeamInvitationLinkFn', () => {
66
74
  beforeEach(() => vi.clearAllMocks())
67
75
 
68
- it('邀请自己 → 拒绝', async () => {
69
- ;(prisma.user.findUnique as any).mockImplementation(async (opts: any) => {
70
- if (opts.where.id === 'owner1') return { id: 'owner1', email: 'a@x.com' }
71
- return null
76
+ it('生成新链接,删除旧链接', async () => {
77
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
78
+ const tx = {
79
+ teamInvitation: {
80
+ deleteMany: vi.fn().mockResolvedValue({}),
81
+ create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'abc123' }),
82
+ },
83
+ teamJoinRequest: {
84
+ updateMany: vi.fn().mockResolvedValue({}),
85
+ },
86
+ }
87
+ return fn(tx)
72
88
  })
73
- await expect(
74
- createTeamInvitationFn({ data: { email: 'a@x.com', seatType: 'member', channel: 'email' } }),
75
- ).rejects.toMatchObject({ message: expect.stringContaining('自己') })
89
+ const result = await refreshTeamInvitationLinkFn({ data: {} })
90
+ expect(result.success).toBe(true)
91
+ expect(result.invitationId).toBe('inv-new')
92
+ expect(result.token).toBeTruthy()
93
+ expect(result.token.length).toBeGreaterThan(0)
76
94
  })
77
95
 
78
- it('目标已在任何团队 → 拒绝', async () => {
79
- ;(prisma.user.findUnique as any).mockImplementation(async (opts: any) => {
80
- if (opts.where.id === 'owner1') return { id: 'owner1', email: 'a@x.com' }
81
- if (opts.where.email === 'b@x.com') return { id: 'u2', email: 'b@x.com' }
82
- return null
96
+ it('支持自定义有效期', async () => {
97
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
98
+ const tx = {
99
+ teamInvitation: {
100
+ deleteMany: vi.fn().mockResolvedValue({}),
101
+ create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'xyz' }),
102
+ },
103
+ teamJoinRequest: {
104
+ updateMany: vi.fn().mockResolvedValue({}),
105
+ },
106
+ }
107
+ return fn(tx)
83
108
  })
84
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
85
- ownerId: 'other',
86
- } as never)
87
- await expect(
88
- createTeamInvitationFn({ data: { email: 'b@x.com', seatType: 'member', channel: 'email' } }),
89
- ).rejects.toMatchObject({ message: expect.stringContaining('已加入') })
109
+ const result = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 14 } })
110
+ expect(result.success).toBe(true)
90
111
  })
91
112
 
92
- it('邮箱已有 pending 邀请 → 拒绝', async () => {
93
- ;(prisma.user.findUnique as any).mockImplementation(async (opts: any) => {
94
- if (opts.where.id === 'owner1') return { id: 'owner1', email: 'a@x.com' }
95
- if (opts.where.email === 'b@x.com') return { id: 'u2', email: 'b@x.com' }
96
- return null
113
+ it('默认有效期 7 天', async () => {
114
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
115
+ const tx = {
116
+ teamInvitation: {
117
+ deleteMany: vi.fn().mockResolvedValue({}),
118
+ create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'tok' }),
119
+ },
120
+ teamJoinRequest: {
121
+ updateMany: vi.fn().mockResolvedValue({}),
122
+ },
123
+ }
124
+ return fn(tx)
97
125
  })
98
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
99
- vi.mocked(prisma.teamMembership.findFirst).mockResolvedValue(null)
100
- vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue({ id: 'inv1' } as never)
101
- await expect(
102
- createTeamInvitationFn({ data: { email: 'b@x.com', seatType: 'member', channel: 'email' } }),
103
- ).rejects.toMatchObject({ message: expect.stringContaining('待处理') })
126
+ await refreshTeamInvitationLinkFn({ data: {} })
127
+ const createCall = vi.mocked(prisma.$transaction).mock.calls[0][0]
128
+ expect(createCall).toBeDefined()
104
129
  })
130
+ })
105
131
 
106
- it('未注册邮箱 + 席位可用 → 创建邀请', async () => {
107
- vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
108
- vi.mocked(prisma.teamMembership.findFirst).mockResolvedValue(null)
109
- vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue(null)
110
- vi.mocked(prisma.teamInvitation.create).mockResolvedValue(
111
- { id: 'inv1', token: 't1' } as never,
112
- )
113
- const result = await createTeamInvitationFn({
114
- data: { email: 'new@x.com', seatType: 'guest', channel: 'link' },
115
- })
116
- expect(prisma.teamInvitation.create).toHaveBeenCalledWith(
132
+ describe('listTeamInvitationsFn', () => {
133
+ beforeEach(() => vi.clearAllMocks())
134
+
135
+ it('返回所有邀请(含已过期链接)', async () => {
136
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 0 } as never)
137
+ vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([
138
+ {
139
+ id: 'inv2',
140
+ ownerId: 'owner1',
141
+ channel: 'email',
142
+ status: 'pending',
143
+ token: 'tok2',
144
+ email: 'b@x.com',
145
+ expiresAt: new Date(Date.now() + 86400000),
146
+ createdAt: new Date(Date.now() + 1000),
147
+ owner: { id: 'owner1', username: 'A', email: 'a@x.com' },
148
+ },
149
+ {
150
+ id: 'inv1',
151
+ ownerId: 'owner1',
152
+ channel: 'link',
153
+ status: 'expired',
154
+ token: 'old',
155
+ expiresAt: new Date(Date.now() - 86400000),
156
+ createdAt: new Date(Date.now() - 86400000),
157
+ owner: { id: 'owner1', username: 'A', email: 'a@x.com' },
158
+ },
159
+ ] as never)
160
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
161
+ vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([] as never)
162
+ const result = await listTeamInvitationsFn()
163
+ expect(result.invitations).toHaveLength(2)
164
+ expect(result.invitations[0].channel).toBe('email')
165
+ expect(result.invitations[1].channel).toBe('link')
166
+ expect(result.joinRequests).toEqual([])
167
+ })
168
+
169
+ it('过期 pending/pending_approval 邀请自动标记为 expired', async () => {
170
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 1 } as never)
171
+ vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([])
172
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
173
+ vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([])
174
+ await listTeamInvitationsFn()
175
+ expect(prisma.teamInvitation.updateMany).toHaveBeenCalledWith(
117
176
  expect.objectContaining({
118
- data: expect.objectContaining({
119
- email: 'new@x.com',
120
- seatType: 'guest',
121
- token: expect.any(String),
122
- expiresAt: expect.any(Date),
177
+ where: expect.objectContaining({
178
+ status: { in: ['pending', 'pending_approval'] },
123
179
  }),
124
180
  }),
125
181
  )
126
- expect(result.success).toBe(true)
127
182
  })
183
+ })
128
184
 
129
- it('link 邀请:已有 pending 链接邀请 → 拒绝(单 pending link 语义)', async () => {
130
- vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue({ id: 'inv0' } as never)
131
- await expect(
132
- createTeamInvitationFn({ data: { seatType: 'member', channel: 'link' } }),
133
- ).rejects.toMatchObject({ message: expect.stringContaining('邀请链接') })
134
- })
185
+ describe('createTeamInvitationFn link 邀请', () => {
186
+ beforeEach(() => vi.clearAllMocks())
135
187
 
136
- it('create 触发 P2002 → 转译为友好错误', async () => {
188
+ it('link 邀请:无旧 pending 链接 → 创建成功', async () => {
137
189
  vi.mocked(prisma.teamInvitation.findFirst).mockResolvedValue(null)
138
- vi.mocked(prisma.teamInvitation.create).mockRejectedValue({ code: 'P2002' } as never)
139
- await expect(
140
- createTeamInvitationFn({ data: { seatType: 'member', channel: 'link' } }),
141
- ).rejects.toMatchObject({ message: expect.stringContaining('待处理') })
190
+ vi.mocked(prisma.teamInvitation.create).mockResolvedValue(
191
+ { id: 'inv1', token: 'abc' } as never,
192
+ )
193
+ const result = await createTeamInvitationFn({ data: { seatType: 'member', channel: 'link' } })
194
+ expect(result.success).toBe(true)
195
+ expect(result.invitationId).toBe('inv1')
196
+ })
197
+ })
198
+
199
+ describe('clearExpiredInvitationsFn', () => {
200
+ beforeEach(() => vi.clearAllMocks())
201
+
202
+ it('清除所有过期邀请', async () => {
203
+ vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 2 } as never)
204
+ const result = await clearExpiredInvitationsFn({ data: {} })
205
+ expect(result.success).toBe(true)
206
+ expect(result.count).toBe(2)
207
+ expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ where: expect.objectContaining({ status: 'expired' }),
210
+ }),
211
+ )
142
212
  })
143
- })
213
+ })