@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
@@ -0,0 +1,281 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@tanstack/react-start', () => ({
4
+ createServerFn: (config: any) => {
5
+ const api: any = {
6
+ _validator: undefined,
7
+ validator(schema: any) {
8
+ this._validator = schema
9
+ return this
10
+ },
11
+ handler(fn: Function) {
12
+ const wrapped = async (args: any) => {
13
+ let data = args?.data
14
+ if (this._validator && data !== undefined) {
15
+ data = this._validator.parse ? this._validator.parse(data) : data
16
+ }
17
+ return fn({ data })
18
+ }
19
+ return wrapped
20
+ },
21
+ }
22
+ return api
23
+ },
24
+ }))
25
+
26
+ vi.mock('@tanstack/react-start/server', () => ({
27
+ getRequest: vi.fn(),
28
+ }))
29
+
30
+ vi.mock('@allbluecn/database', () => ({
31
+ prisma: {
32
+ user: { findUnique: vi.fn() },
33
+ teamInvitation: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), create: vi.fn(), findMany: vi.fn() },
34
+ teamMembership: { findUnique: vi.fn(), create: vi.fn() },
35
+ teamJoinRequest: { findUnique: vi.fn(), upsert: vi.fn(), update: vi.fn(), updateMany: vi.fn(), findMany: vi.fn() },
36
+ $transaction: vi.fn(),
37
+ },
38
+ }))
39
+
40
+ vi.mock('@/server/auth-helpers', () => ({
41
+ requireAuth: vi.fn().mockResolvedValue({ user: { id: 'u2', email: 'b@x.com' } }),
42
+ }))
43
+
44
+ vi.mock('@/server/audit', () => ({
45
+ auditLog: vi.fn(),
46
+ }))
47
+
48
+ vi.mock('@/server/resources', () => ({
49
+ requireTeamSeats: vi.fn().mockResolvedValue(undefined),
50
+ }))
51
+
52
+ import { prisma } from '@allbluecn/database'
53
+ import { applyTeamInvitationFn, approveTeamJoinRequestFn, rejectTeamJoinRequestFn, getTeamInvitationByTokenFn, refreshTeamInvitationLinkFn, listTeamInvitationsFn } from '../team'
54
+
55
+ vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn(prisma))
56
+
57
+ const LINK_INV = {
58
+ id: 'inv1', token: 't1', status: 'pending', seatType: 'member', channel: 'link',
59
+ email: null, expiresAt: new Date(Date.now() + 60000), ownerId: 'owner1',
60
+ owner: { id: 'owner1', username: 'ow', email: 'owner@x.com' },
61
+ }
62
+
63
+ describe('applyTeamInvitationFn(link 渠道)', () => {
64
+ beforeEach(() => vi.clearAllMocks())
65
+
66
+ it('链接不存在 → 拒绝', async () => {
67
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(null as never)
68
+ await expect(applyTeamInvitationFn({ data: { token: 'tX', email: 'a@x.com' } }))
69
+ .rejects.toMatchObject({ message: expect.stringContaining('不存在') })
70
+ })
71
+
72
+ it('链接过期 → 拒绝', async () => {
73
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
74
+ await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'a@x.com' } }))
75
+ .rejects.toMatchObject({ message: expect.stringContaining('过期') })
76
+ })
77
+
78
+ it('邮箱是 owner 自己 → 拒绝(不能加入自己的团队)', async () => {
79
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
80
+ await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'owner@x.com' } }))
81
+ .rejects.toMatchObject({ message: expect.stringContaining('自己的团队') })
82
+ })
83
+
84
+ it('邮箱已注册且该账号已加入团队 → 拒绝', async () => {
85
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
86
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
87
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'm1' } as never)
88
+ await expect(applyTeamInvitationFn({ data: { token: 't1', email: 'taken@x.com' } }))
89
+ .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
90
+ })
91
+
92
+ it('新邮箱 → 创建 TeamJoinRequest,且不改 TeamInvitation 状态', async () => {
93
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
94
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never)
95
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
96
+ vi.mocked(prisma.teamJoinRequest.upsert).mockResolvedValue({ id: 'jr1' } as never)
97
+
98
+ const res = await applyTeamInvitationFn({ data: { token: 't1', email: 'new@x.com' } })
99
+
100
+ expect(res).toMatchObject({ success: true, requiresApproval: true })
101
+ expect(prisma.teamJoinRequest.upsert).toHaveBeenCalledWith(expect.objectContaining({
102
+ where: { invitationId_email: { invitationId: 'inv1', email: 'new@x.com' } },
103
+ }))
104
+ expect(prisma.teamInvitation.update).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('同链接同邮箱已有 pending 申请 → 幂等返回,不重复创建', async () => {
108
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
109
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never)
110
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ id: 'jr1', status: 'pending' } as never)
111
+
112
+ const res = await applyTeamInvitationFn({ data: { token: 't1', email: 'dup@x.com' } })
113
+
114
+ expect(res).toMatchObject({ success: true, duplicate: true })
115
+ expect(prisma.teamJoinRequest.upsert).not.toHaveBeenCalled()
116
+ })
117
+ })
118
+
119
+ const PENDING_REQ = {
120
+ id: 'jr1', invitationId: 'inv1', ownerId: 'u2', userId: 'u9',
121
+ email: 'new@x.com', status: 'pending', seatType: 'member',
122
+ invitation: { id: 'inv1', token: 't1', status: 'pending', channel: 'link', expiresAt: new Date(Date.now() + 60000) },
123
+ }
124
+
125
+ describe('approveTeamJoinRequestFn', () => {
126
+ beforeEach(() => vi.clearAllMocks())
127
+
128
+ it('申请不存在或非本人 → 拒绝', async () => {
129
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
130
+ await expect(approveTeamJoinRequestFn({ data: { requestId: 'jrX', seatType: 'member' } }))
131
+ .rejects.toMatchObject({ message: expect.stringContaining('申请不存在') })
132
+ })
133
+
134
+ it('申请人已加入其他团队 → 友好拒绝', async () => {
135
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
136
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: 'mX' } as never)
137
+ await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
138
+ .rejects.toMatchObject({ message: expect.stringContaining('已加入团队') })
139
+ })
140
+
141
+ it('链接已过期 → 拒绝并同步失效申请', async () => {
142
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({
143
+ ...PENDING_REQ, invitation: { ...PENDING_REQ.invitation, expiresAt: new Date(Date.now() - 1000) },
144
+ } as never)
145
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
146
+ await expect(approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } }))
147
+ .rejects.toMatchObject({ message: expect.stringContaining('过期') })
148
+ expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
149
+ data: expect.objectContaining({ status: 'invalidated' }),
150
+ }))
151
+ })
152
+
153
+ it('批准 → 事务创建 membership 并置 approved', async () => {
154
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
155
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
156
+ vi.mocked(prisma.teamMembership.create).mockResolvedValue({ id: 'm1' } as never)
157
+ vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
158
+
159
+ const res = await approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'guest' } })
160
+
161
+ expect(res).toEqual({ success: true })
162
+ expect(prisma.teamMembership.create).toHaveBeenCalledWith(expect.objectContaining({
163
+ data: { ownerId: 'u2', memberId: 'u9', seatType: 'guest' },
164
+ }))
165
+ expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
166
+ where: { id: 'jr1' },
167
+ data: expect.objectContaining({ status: 'approved', seatType: 'guest' }),
168
+ }))
169
+ })
170
+
171
+ it('userId 为空时按 email 回查申请人', async () => {
172
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, userId: null } as never)
173
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
174
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 'u9' } as never)
175
+ vi.mocked(prisma.teamMembership.create).mockResolvedValue({ id: 'm1' } as never)
176
+ vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
177
+
178
+ await approveTeamJoinRequestFn({ data: { requestId: 'jr1', seatType: 'member' } })
179
+
180
+ expect(prisma.user.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { email: 'new@x.com' } }))
181
+ expect(prisma.teamMembership.create).toHaveBeenCalledWith(expect.objectContaining({
182
+ data: expect.objectContaining({ memberId: 'u9' }),
183
+ }))
184
+ })
185
+ })
186
+
187
+ describe('rejectTeamJoinRequestFn', () => {
188
+ beforeEach(() => vi.clearAllMocks())
189
+
190
+ it('拒绝 → 置 rejected 并记录 processedAt', async () => {
191
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(PENDING_REQ as never)
192
+ vi.mocked(prisma.teamJoinRequest.update).mockResolvedValue({ id: 'jr1' } as never)
193
+
194
+ await rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } })
195
+
196
+ expect(prisma.teamJoinRequest.update).toHaveBeenCalledWith(expect.objectContaining({
197
+ where: { id: 'jr1' },
198
+ data: expect.objectContaining({ status: 'rejected' }),
199
+ }))
200
+ })
201
+
202
+ it('非 pending 状态 → 拒绝操作', async () => {
203
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ ...PENDING_REQ, status: 'approved' } as never)
204
+ await expect(rejectTeamJoinRequestFn({ data: { requestId: 'jr1' } }))
205
+ .rejects.toMatchObject({ message: expect.stringContaining('待审批') })
206
+ })
207
+ })
208
+
209
+ describe('getTeamInvitationByTokenFn(link 渠道)', () => {
210
+ beforeEach(() => vi.clearAllMocks())
211
+
212
+ it('登录用户有 pending 申请 → 返回 myRequestStatus=pending', async () => {
213
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
214
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null as never)
215
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: 'new@x.com' } as never)
216
+ vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({ status: 'pending' } as never)
217
+
218
+ const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: 'u9' } })
219
+
220
+ expect(res).toMatchObject({ effectiveStatus: 'pending', myRequestStatus: 'pending', alreadyJoined: false })
221
+ })
222
+
223
+ it('链接过期 → 置 expired 并 invalid 关联 pending 申请', async () => {
224
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({ ...LINK_INV, expiresAt: new Date(Date.now() - 1000) } as never)
225
+ vi.mocked(prisma.teamInvitation.update).mockResolvedValue({} as never)
226
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 2 } as never)
227
+
228
+ const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: null } })
229
+
230
+ expect(res).toMatchObject({ effectiveStatus: 'expired' })
231
+ expect(prisma.teamJoinRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
232
+ where: { invitationId: 'inv1', status: 'pending' },
233
+ }))
234
+ })
235
+
236
+ it('登录用户已加入团队 → alreadyJoined=true', async () => {
237
+ vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue(LINK_INV as never)
238
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ ownerId: 'other' } as never)
239
+
240
+ const res = await getTeamInvitationByTokenFn({ data: { token: 't1', userId: 'u9' } })
241
+
242
+ expect(res).toMatchObject({ alreadyJoined: true, joinedOwnerId: 'other', myRequestStatus: null })
243
+ })
244
+ })
245
+
246
+ describe('refreshTeamInvitationLinkFn', () => {
247
+ beforeEach(() => vi.clearAllMocks())
248
+
249
+ it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接删除,新链接创建', async () => {
250
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
251
+ vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 1 } as never)
252
+ vi.mocked(prisma.teamInvitation.create).mockResolvedValue({ id: 'inv2', token: 'NEW' } as never)
253
+
254
+ const res = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 7 } })
255
+
256
+ expect(res).toMatchObject({ success: true })
257
+ expect(prisma.teamJoinRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
258
+ where: expect.objectContaining({ ownerId: 'u2', status: 'pending' }),
259
+ data: expect.objectContaining({ status: 'invalidated' }),
260
+ }))
261
+ expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(expect.objectContaining({
262
+ where: { ownerId: 'u2', channel: 'link', status: { in: ['pending', 'pending_approval'] } },
263
+ }))
264
+ expect(prisma.teamInvitation.create).toHaveBeenCalled()
265
+ })
266
+ })
267
+
268
+ describe('listTeamInvitationsFn', () => {
269
+ beforeEach(() => vi.clearAllMocks())
270
+
271
+ it('返回 { invitations, joinRequests } 结构', async () => {
272
+ vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 0 } as never)
273
+ vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 0 } as never)
274
+ vi.mocked(prisma.teamInvitation.findMany).mockResolvedValue([{ id: 'inv1' }] as never)
275
+ vi.mocked(prisma.teamJoinRequest.findMany).mockResolvedValue([{ id: 'jr1' }] as never)
276
+
277
+ const res = await listTeamInvitationsFn()
278
+
279
+ expect(res).toEqual({ invitations: [{ id: 'inv1' }], joinRequests: [{ id: 'jr1' }] })
280
+ })
281
+ })
@@ -33,10 +33,21 @@ vi.mock('@allbluecn/database', () => ({
33
33
  teamMembership: {
34
34
  findUnique: vi.fn(),
35
35
  findMany: vi.fn(),
36
+ findFirst: vi.fn(),
36
37
  delete: vi.fn(),
38
+ create: vi.fn(),
37
39
  count: vi.fn(),
38
40
  },
39
- teamInvitation: { count: vi.fn() },
41
+ teamInvitation: {
42
+ create: vi.fn(),
43
+ findFirst: vi.fn(),
44
+ findUnique: vi.fn(),
45
+ updateMany: vi.fn(),
46
+ findMany: vi.fn(),
47
+ update: vi.fn(),
48
+ count: vi.fn(),
49
+ deleteMany: vi.fn(),
50
+ },
40
51
  projectMember: { deleteMany: vi.fn() },
41
52
  $transaction: vi.fn(),
42
53
  },
@@ -55,17 +66,13 @@ vi.mock('@/server/audit', () => ({
55
66
  }))
56
67
 
57
68
  import { prisma } from '@allbluecn/database'
58
- import {
59
- listTeamMembersFn,
60
- removeTeamMemberFn,
61
- leaveTeamFn,
62
- listTeamUsersFn,
63
- } from '../team'
69
+ import { listTeamMembersFn } from '../team'
64
70
 
65
71
  describe('listTeamMembersFn', () => {
66
72
  beforeEach(() => vi.clearAllMocks())
67
73
 
68
- it('owner 列出成员', async () => {
74
+ it('已有 membership → 正常列出成员', async () => {
75
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
69
76
  vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
70
77
  {
71
78
  id: 'm1',
@@ -78,124 +85,38 @@ describe('listTeamMembersFn', () => {
78
85
  ] as never)
79
86
  const result = await listTeamMembersFn()
80
87
  expect(result).toHaveLength(1)
81
- expect(result[0].seatType).toBe('member')
88
+ expect((result as any)[0].seatType).toBe('member')
89
+ expect((result as any)[0].memberId).toBe('u2')
82
90
  })
83
- })
84
-
85
- describe('removeTeamMemberFn', () => {
86
- beforeEach(() => vi.clearAllMocks())
87
-
88
- it('owner 移除成员 → 事务删 membership + 级联删 ProjectMember', async () => {
89
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
90
- id: 'm1',
91
- ownerId: 'owner1',
92
- memberId: 'u2',
93
- seatType: 'member',
94
- } as never)
95
- let capturedTx: any
96
- vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
97
- capturedTx = {
98
- teamMembership: { delete: vi.fn().mockResolvedValue({}) },
99
- projectMember: { deleteMany: vi.fn().mockResolvedValue({ count: 3 }) },
100
- }
101
- return fn(capturedTx)
102
- })
103
- const result = await removeTeamMemberFn({ data: { membershipId: 'm1' } })
104
- expect(result.success).toBe(true)
105
- expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
106
- expect.objectContaining({ where: { userId: 'u2' } }),
107
- )
108
- })
109
-
110
- it('非 owner 移除 → 拒绝', async () => {
111
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
112
- id: 'm1',
113
- ownerId: 'other',
114
- memberId: 'u2',
115
- } as never)
116
- await expect(removeTeamMemberFn({ data: { membershipId: 'm1' } }))
117
- .rejects.toMatchObject({ message: expect.stringContaining('权限') })
118
- })
119
-
120
- it('成员不存在 → 拒绝', async () => {
121
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
122
- await expect(removeTeamMemberFn({ data: { membershipId: 'm999' } }))
123
- .rejects.toMatchObject({ message: expect.stringContaining('不存在') })
124
- })
125
- })
126
-
127
- describe('leaveTeamFn', () => {
128
- beforeEach(() => vi.clearAllMocks())
129
91
 
130
- it('未加入团队 → 拒绝', async () => {
92
+ it('无 membership → 自动创建 owner 记录', async () => {
131
93
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
132
- await expect(leaveTeamFn()).rejects.toMatchObject({ message: expect.stringContaining('未加入') })
133
- })
134
-
135
- it('已加入团队 → 退出并级联删 ProjectMember', async () => {
136
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
137
- id: 'm1',
94
+ vi.mocked(prisma.teamMembership.create).mockResolvedValue({
95
+ id: 'm-new',
138
96
  ownerId: 'owner1',
139
97
  memberId: 'owner1',
98
+ seatType: 'owner',
99
+ joinedAt: new Date(),
100
+ member: { id: 'owner1', username: 'A', email: 'a@x.com', avatarConfig: null, bgShape: null, bgColor: null },
140
101
  } as never)
141
- let capturedTx: any
142
- vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
143
- capturedTx = {
144
- teamMembership: { delete: vi.fn().mockResolvedValue({}) },
145
- projectMember: { deleteMany: vi.fn().mockResolvedValue({ count: 2 }) },
146
- }
147
- return fn(capturedTx)
148
- })
149
- const result = await leaveTeamFn()
150
- expect(result.success).toBe(true)
151
- expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
152
- expect.objectContaining({ where: { userId: 'owner1' } }),
102
+ const result = await listTeamMembersFn()
103
+ expect(prisma.teamMembership.create).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ data: expect.objectContaining({ ownerId: 'owner1', memberId: 'owner1', seatType: 'owner' }),
106
+ }),
153
107
  )
154
- })
155
- })
156
-
157
- describe('listTeamUsersFn', () => {
158
- beforeEach(() => vi.clearAllMocks())
159
-
160
- it('返回 owner + active 成员(含 seatType)', async () => {
161
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
162
- id: 'owner1',
163
- username: 'A',
164
- email: 'a@x.com',
165
- avatarConfig: null,
166
- bgShape: null,
167
- bgColor: null,
168
- } as never)
169
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
170
- ownerId: 'owner1',
171
- memberId: 'owner1',
172
- } as never)
173
- vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
174
- { seatType: 'member', member: { id: 'u2', username: 'B', email: 'b@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
175
- { seatType: 'guest', member: { id: 'u3', username: 'C', email: 'c@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
176
- ] as never)
177
- const result = await listTeamUsersFn()
178
- expect(result).toHaveLength(3)
108
+ expect(result).toHaveLength(1)
179
109
  expect(result[0].seatType).toBe('owner')
180
- expect(result[0].username).toBe('A')
181
- expect(result[1].seatType).toBe('member')
182
- expect(result[2].seatType).toBe('guest')
110
+ expect(result[0].memberId).toBe('owner1')
183
111
  })
184
112
 
185
- it('未加入团队的用户 → 返回自身作为 owner', async () => {
186
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
187
- id: 'owner1',
188
- username: 'Solo',
189
- email: 'solo@x.com',
190
- avatarConfig: null,
191
- bgShape: null,
192
- bgColor: null,
193
- } as never)
194
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
195
- vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([] as never)
196
- const result = await listTeamUsersFn()
197
- expect(result).toHaveLength(1)
198
- expect(result[0].seatType).toBe('owner')
199
- expect(result[0].username).toBe('Solo')
113
+ it('已有 membership → 不创建,直接查询', async () => {
114
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
115
+ vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([])
116
+ await listTeamMembersFn()
117
+ expect(prisma.teamMembership.create).not.toHaveBeenCalled()
118
+ expect(prisma.teamMembership.findMany).toHaveBeenCalledWith(
119
+ expect.objectContaining({ where: { ownerId: 'owner1' } }),
120
+ )
200
121
  })
201
- })
122
+ })
@@ -15,17 +15,12 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { createCollection } from '@tanstack/db'
19
- import { queryCollectionOptions } from '@tanstack/query-db-collection'
20
- import { listAiProvidersFn } from '@/server/serverFns/ai/providers'
21
- import { getQueryClient } from '@allbluecn/shared/lib/query-client-seam'
18
+ import { createServerFn } from '@tanstack/react-start'
19
+ import { prisma } from '@allbluecn/database'
22
20
 
23
- export const aiProvidersCollection = createCollection(
24
- queryCollectionOptions({
25
- id: 'ai-providers',
26
- queryKey: ['ai-providers'],
27
- queryFn: () => listAiProvidersFn(),
28
- getKey: (item) => item.meta.providerId,
29
- queryClient: getQueryClient(),
21
+ export const checkAdminExistsFn = createServerFn({ method: 'GET' }).handler(async () => {
22
+ const count = await prisma.user.count({
23
+ where: { role: 'SUPER_ADMIN' },
30
24
  })
31
- )
25
+ return { hasAdmin: count > 0 }
26
+ })
@@ -32,6 +32,17 @@ const requireBilling = () => {
32
32
  return billing
33
33
  }
34
34
 
35
+ async function emitBillingNotification(userId: string, type: string, title: string, body: string, link?: string) {
36
+ void (async () => {
37
+ try {
38
+ const { createNotification } = await import('@/server/notifications/notification-service')
39
+ await createNotification({ userId, category: 'billing', type, title, body, link })
40
+ } catch (e) {
41
+ console.error('[billing] 通知发送失败:', e instanceof Error ? e.message : 'unknown')
42
+ }
43
+ })()
44
+ }
45
+
35
46
  export const getEffectivePlanFn = createServerFn({ method: 'GET' }).handler(async () => {
36
47
  const request = getRequest()
37
48
  const session = await requireAuth(request)
@@ -92,10 +103,12 @@ export const createCustomerPortalSessionFn = createServerFn({ method: 'POST' }).
92
103
  const userId = session.user!.id!
93
104
 
94
105
  const baseUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
95
- return requireBilling().createCustomerPortalSession({
106
+ const result = await requireBilling().createCustomerPortalSession({
96
107
  userId,
97
108
  returnUrl: `${baseUrl}/settings/billing`,
98
109
  })
110
+ await emitBillingNotification(userId, 'billing.portal_access', '客户门户已打开', '您已打开管理订阅的客户门户', '/settings/billing')
111
+ return result
99
112
  },
100
113
  )
101
114
 
@@ -159,9 +172,17 @@ export const updateSeatsFn = createServerFn({ method: 'POST' })
159
172
  )
160
173
  }
161
174
 
162
- return requireBilling().updateSeats({
175
+ const result = await requireBilling().updateSeats({
163
176
  userId,
164
177
  addonSeats: data.addonSeats,
165
178
  successUrl: data.successUrl ?? `${baseUrl}/settings/billing?checkout=success`,
166
179
  })
180
+ await emitBillingNotification(
181
+ userId,
182
+ 'billing.seats_changed',
183
+ '席位变更',
184
+ `席位已调整为 ${subscription.plan.maxSeats + data.addonSeats}`,
185
+ '/settings/billing',
186
+ )
187
+ return result
167
188
  })