@allbluecn/web-app 0.4.17 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +1 -1
  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/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. package/vite.shared.ts +13 -0
@@ -14,16 +14,24 @@
14
14
  //
15
15
  // You should have received a copy of this license.
16
16
 
17
- import { createFileRoute, redirect } from '@tanstack/react-router'
17
+ import { createFileRoute } from '@tanstack/react-router'
18
+ import { getTeamInvitationByTokenFn } from '@/server/serverFns/team'
18
19
 
19
20
  export const Route = createFileRoute('/invite/$token')({
20
- beforeLoad: async ({ context, params, location }) => {
21
- if (!context.session?.user?.id) {
22
- throw redirect({
23
- to: '/login',
24
- search: { redirect: location.pathname + (location.searchStr ?? '') },
25
- })
21
+ beforeLoad: async ({ context, params }) => {
22
+ const sessionUser = context.session?.user
23
+ const userId = sessionUser?.id ?? null
24
+ let inv = null
25
+ try {
26
+ inv = await getTeamInvitationByTokenFn({ data: { token: params.token, userId } })
27
+ } catch {
28
+ // invitation lookup failure, return null to let component handle
29
+ }
30
+ return {
31
+ token: params.token,
32
+ isLoggedIn: !!sessionUser?.id,
33
+ userId,
34
+ inv,
26
35
  }
27
- return { token: params.token }
28
36
  },
29
37
  })
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_3 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/share/prd/$prdId')({
7
+ beforeLoad: ((ctx: PluginRouteContext) => prd_route_3.beforeLoad(ctx)) as any,
8
+ validateSearch: prd_route_3.validateSearch as any,
9
+ component: prd_route_3.Component as any,
10
+ })
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ vi.mock('@allbluecn/database', () =>
5
+ ({
6
+ prisma: {
7
+ notificationPreference: {
8
+ findUnique: vi.fn(),
9
+ create: vi.fn(),
10
+ update: vi.fn(),
11
+ },
12
+ notification: {
13
+ count: vi.fn(),
14
+ create: vi.fn(),
15
+ },
16
+ },
17
+ }) as any,
18
+ )
19
+
20
+ import { prisma } from '@allbluecn/database'
21
+ import { createNotification, isInDndWindow, DEFAULT_CATEGORIES } from '../notification-service'
22
+ import { notificationBus } from '../event-bus'
23
+
24
+ const mockPreferenceFindUnique = prisma.notificationPreference.findUnique as ReturnType<typeof vi.fn>
25
+ const mockPreferenceCreate = prisma.notificationPreference.create as ReturnType<typeof vi.fn>
26
+ const mockNotificationCreate = prisma.notification.create as ReturnType<typeof vi.fn>
27
+
28
+ describe('isInDndWindow', () => {
29
+ it('跨午夜区间:23:00 在 22:00-08:00 内', () => {
30
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '22:00', dndEnd: '08:00' }, '23:00')).toBe(true)
31
+ })
32
+ it('跨午夜区间:06:30 在 22:00-08:00 内', () => {
33
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '22:00', dndEnd: '08:00' }, '06:30')).toBe(true)
34
+ })
35
+ it('跨午夜区间:12:00 不在 22:00-08:00 内', () => {
36
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '22:00', dndEnd: '08:00' }, '12:00')).toBe(false)
37
+ })
38
+ it('非跨午夜区间:13:00 在 09:00-18:00 内', () => {
39
+ expect(isInDndWindow({ dndEnabled: true, dndStart: '09:00', dndEnd: '18:00' }, '13:00')).toBe(true)
40
+ })
41
+ it('DND 关闭时恒为 false', () => {
42
+ expect(isInDndWindow({ dndEnabled: false, dndStart: '22:00', dndEnd: '08:00' }, '23:00')).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('createNotification', () => {
47
+ beforeEach(() => {
48
+ vi.clearAllMocks()
49
+ })
50
+
51
+ it('分类关闭时丢弃:不写库不 emit', async () => {
52
+ mockPreferenceFindUnique.mockResolvedValue({
53
+ userId: 'u1',
54
+ categories: { team: false, collaboration: true, system: true, billing: true, security: true },
55
+ dndEnabled: false,
56
+ dndStart: '22:00',
57
+ dndEnd: '08:00',
58
+ } as never)
59
+ const emitSpy = vi.spyOn(notificationBus, 'emit')
60
+
61
+ await createNotification({ userId: 'u1', category: 'team', type: 'team.invite_request', title: 't' })
62
+
63
+ expect(mockNotificationCreate).not.toHaveBeenCalled()
64
+ expect(emitSpy).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('分类开启时写库并 emit', async () => {
68
+ mockPreferenceFindUnique.mockResolvedValue(null)
69
+ mockPreferenceCreate.mockResolvedValue({} as never)
70
+ mockNotificationCreate.mockImplementation(async () =>
71
+ ({
72
+ userId: 'u1',
73
+ category: 'team',
74
+ type: 'team.invite_request',
75
+ title: 't',
76
+ body: 'b',
77
+ link: '/settings/team',
78
+ id: 'n1',
79
+ createdAt: new Date(),
80
+ readAt: null,
81
+ } as never),
82
+ )
83
+ const emitSpy = vi.spyOn(notificationBus, 'emit')
84
+
85
+ await createNotification({
86
+ userId: 'u1',
87
+ category: 'team',
88
+ type: 'team.invite_request',
89
+ title: 't',
90
+ body: 'b',
91
+ link: '/settings/team',
92
+ })
93
+
94
+ expect(mockNotificationCreate).toHaveBeenCalled()
95
+ expect(emitSpy).toHaveBeenCalledWith('u1', expect.objectContaining({ kind: 'notification' }))
96
+ })
97
+
98
+ it('通知失败不抛出(fire-and-forget 安全)', async () => {
99
+ mockPreferenceFindUnique.mockRejectedValue(new Error('db down'))
100
+ await expect(
101
+ createNotification({ userId: 'u1', category: 'team', type: 'team.invite_request', title: 't' }),
102
+ ).resolves.toBeUndefined()
103
+ })
104
+
105
+ it('DEFAULT_CATEGORIES 全开', () => {
106
+ expect(DEFAULT_CATEGORIES).toEqual({
107
+ team: true,
108
+ collaboration: true,
109
+ system: true,
110
+ billing: true,
111
+ security: true,
112
+ })
113
+ })
114
+ })
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect } from 'vitest'
3
+ import { notificationBus } from '../event-bus'
4
+
5
+ describe('notificationBus', () => {
6
+ it('subscribe 收到 emit 的值;unsubscribe 后不再收到', () => {
7
+ const received: unknown[] = []
8
+ const unsubscribe = notificationBus.subscribe('test-user', (v) =>
9
+ received.push(v),
10
+ )
11
+
12
+ notificationBus.emit('test-user', { kind: 'unread_snapshot', count: 3 })
13
+ expect(received).toEqual([{ kind: 'unread_snapshot', count: 3 }])
14
+
15
+ unsubscribe()
16
+ notificationBus.emit('test-user', { kind: 'unread_snapshot', count: 4 })
17
+ expect(received).toHaveLength(1)
18
+ })
19
+
20
+ it('不同 userId 互不干扰', () => {
21
+ const a: unknown[] = []
22
+ const b: unknown[] = []
23
+ notificationBus.subscribe('user-a', (v) => a.push(v))
24
+ notificationBus.subscribe('user-b', (v) => b.push(v))
25
+
26
+ notificationBus.emit('user-a', { kind: 'unread_snapshot', count: 1 })
27
+ expect(a).toHaveLength(1)
28
+ expect(b).toHaveLength(0)
29
+ })
30
+ })
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { EventEmitter } from 'node:events'
3
+ import type { NotificationSseValue } from '@allbluecn/shared'
4
+
5
+ const emitter = new EventEmitter()
6
+ emitter.setMaxListeners(0)
7
+
8
+ export const notificationBus = {
9
+ emit(userId: string, value: NotificationSseValue) {
10
+ emitter.emit(`user:${userId}`, value)
11
+ },
12
+ subscribe(userId: string, listener: (value: NotificationSseValue) => void) {
13
+ emitter.on(`user:${userId}`, listener)
14
+ return () => emitter.off(`user:${userId}`, listener)
15
+ },
16
+ }
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { prisma } from '@allbluecn/database'
3
+ import type {
4
+ CategoryPreferences,
5
+ NotificationCategory,
6
+ NotificationItem,
7
+ NotificationPreferences,
8
+ } from '@allbluecn/shared'
9
+ import { isInDndWindow, DEFAULT_CATEGORIES } from '@allbluecn/shared'
10
+ import { notificationBus } from './event-bus'
11
+
12
+ export { DEFAULT_CATEGORIES, isInDndWindow } from '@allbluecn/shared'
13
+
14
+ export interface CreateNotificationInput {
15
+ userId: string
16
+ category: NotificationCategory
17
+ type: string
18
+ title: string
19
+ body?: string
20
+ link?: string
21
+ }
22
+
23
+ export async function getOrCreatePreferences(userId: string): Promise<NotificationPreferences> {
24
+ const existing = await prisma.notificationPreference.findUnique({ where: { userId } })
25
+ if (existing) {
26
+ return {
27
+ categories: { ...DEFAULT_CATEGORIES, ...(existing.categories as object) } as CategoryPreferences,
28
+ dndEnabled: existing.dndEnabled,
29
+ dndStart: existing.dndStart,
30
+ dndEnd: existing.dndEnd,
31
+ }
32
+ }
33
+ const defaults: NotificationPreferences = {
34
+ categories: { ...DEFAULT_CATEGORIES },
35
+ dndEnabled: false,
36
+ dndStart: '22:00',
37
+ dndEnd: '08:00',
38
+ }
39
+ try {
40
+ await prisma.notificationPreference.create({
41
+ data: {
42
+ userId,
43
+ categories: defaults.categories,
44
+ dndEnabled: defaults.dndEnabled,
45
+ dndStart: defaults.dndStart,
46
+ dndEnd: defaults.dndEnd,
47
+ },
48
+ })
49
+ } catch {
50
+ // 并发下唯一键冲突 = 已被其他请求创建,忽略
51
+ }
52
+ return defaults
53
+ }
54
+
55
+ /** fire-and-forget:任何失败只记日志不抛出,不阻断业务主流程 */
56
+ export async function createNotification(input: CreateNotificationInput): Promise<void> {
57
+ try {
58
+ const prefs = await getOrCreatePreferences(input.userId)
59
+ if (!prefs.categories[input.category]) return
60
+
61
+ const created = await prisma.notification.create({
62
+ data: {
63
+ userId: input.userId,
64
+ category: input.category,
65
+ type: input.type,
66
+ title: input.title,
67
+ body: input.body,
68
+ link: input.link,
69
+ },
70
+ })
71
+
72
+ const item: NotificationItem = {
73
+ id: created.id,
74
+ category: created.category as NotificationCategory,
75
+ type: created.type,
76
+ title: created.title,
77
+ body: created.body,
78
+ link: created.link,
79
+ readAt: created.readAt ? created.readAt.toISOString() : null,
80
+ createdAt: created.createdAt.toISOString(),
81
+ }
82
+ notificationBus.emit(input.userId, { kind: 'notification', notification: item })
83
+ } catch (e) {
84
+ console.error('[notifications] create failed:', e instanceof Error ? e.message : 'unknown')
85
+ }
86
+ }
@@ -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
  })
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ vi.mock('@tanstack/react-start', () => ({
5
+ createServerFn: () => ({
6
+ validator: () => ({
7
+ handler: (fn: unknown) => fn,
8
+ }),
9
+ }),
10
+ }))
11
+ vi.mock('@tanstack/react-start/server', () => ({
12
+ getRequest: vi.fn(),
13
+ }))
14
+ vi.mock('@/server/auth-helpers', () => ({
15
+ requireAuth: vi.fn(),
16
+ }))
17
+ vi.mock('@allbluecn/database', () => ({
18
+ prisma: {
19
+ notification: {
20
+ findMany: vi.fn(),
21
+ updateMany: vi.fn(),
22
+ deleteMany: vi.fn(),
23
+ count: vi.fn(),
24
+ },
25
+ notificationPreference: {
26
+ findUnique: vi.fn(),
27
+ create: vi.fn(),
28
+ upsert: vi.fn(),
29
+ },
30
+ },
31
+ }))
32
+
33
+ import { prisma } from '@allbluecn/database'
34
+ import { requireAuth } from '@/server/auth-helpers'
35
+ import { markNotificationReadFn, deleteNotificationFn } from '../notifications'
36
+
37
+ describe('notifications serverFn 越权防护', () => {
38
+ beforeEach(() => vi.clearAllMocks())
39
+
40
+ it('markAsRead 的 where 同时含 id 与 userId', async () => {
41
+ vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'me' } } as never)
42
+ vi.mocked(prisma.notification.updateMany).mockResolvedValue({ count: 0 } as never)
43
+
44
+ await (markNotificationReadFn as unknown as (args: { data: unknown }) => Promise<unknown>)(
45
+ { data: { id: 'other-user-notification' } },
46
+ )
47
+
48
+ const arg = vi.mocked(prisma.notification.updateMany).mock.calls.at(-1)![0]
49
+ expect(arg?.where).toMatchObject({ id: 'other-user-notification', userId: 'me' })
50
+ })
51
+
52
+ it('delete 的 where 同时含 id 与 userId', async () => {
53
+ vi.mocked(requireAuth).mockResolvedValue({ user: { id: 'me' } } as never)
54
+ vi.mocked(prisma.notification.deleteMany).mockResolvedValue({ count: 0 } as never)
55
+
56
+ await (deleteNotificationFn as unknown as (args: { data: unknown }) => Promise<unknown>)({
57
+ data: { id: 'x' },
58
+ })
59
+
60
+ const arg = vi.mocked(prisma.notification.deleteMany).mock.calls.at(-1)![0]
61
+ expect(arg?.where).toMatchObject({ id: 'x', userId: 'me' })
62
+ })
63
+ })
@@ -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
  })