@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.
- package/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +10 -0
- package/src/routeTree.gen.ts +70 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- 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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|
package/src/server/resources.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
33
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
})
|