@allbluecn/web-app 0.4.22 → 0.5.0
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 +8 -8
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +114 -51
- package/src/components/story/detail/story-detail-body.tsx +13 -3
- package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +17 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +1 -2
- package/src/server/serverFns/story/story-dependencies.ts +107 -163
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/team.ts +51 -2
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/tab.tsx +0 -212
- package/src/config/sidebar-notifications.ts +0 -50
- package/src/plugins/.gen-manifest.json +0 -13
- package/src/plugins/modules.gen.ts +0 -20
- package/src/plugins/plugins.gen.ts +0 -20
- package/src/plugins/ui.gen.ts +0 -8
- package/src/routes/_nav/inspiration/route.tsx +0 -8
- package/src/routes/_nav/prd/$id/route.tsx +0 -9
- package/src/routes/_nav/prd/index.tsx +0 -9
- package/src/routes/_nav/prd/route.tsx +0 -8
- package/src/routes/_nav/tags/$id/route.tsx +0 -8
- package/src/routes/_nav/tags/index.tsx +0 -8
- package/src/routes/_nav/tags/route.tsx +0 -8
- package/src/routes/_nav/tags/settings/route.tsx +0 -9
- package/src/routes/share/prd/$prdId/route.tsx +0 -10
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('@allbluecn/database', () => ({
|
|
5
|
+
prisma: {
|
|
6
|
+
notificationPreference: {
|
|
7
|
+
findUnique: vi.fn(),
|
|
8
|
+
create: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
notification: {
|
|
11
|
+
create: vi.fn(),
|
|
12
|
+
findFirst: vi.fn(),
|
|
13
|
+
findMany: vi.fn(),
|
|
14
|
+
count: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
Prisma: {},
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock('@/server/notifications/event-bus', () => ({
|
|
21
|
+
notificationBus: {
|
|
22
|
+
emit: vi.fn(),
|
|
23
|
+
subscribe: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import { prisma } from '@allbluecn/database'
|
|
28
|
+
import { notificationBus } from '@/server/notifications/event-bus'
|
|
29
|
+
import { createNotification, deliverDueDigests } from '../notification-service'
|
|
30
|
+
|
|
31
|
+
const mockedFindUnique = prisma.notificationPreference.findUnique as ReturnType<typeof vi.fn>
|
|
32
|
+
const mockedCreatePref = prisma.notificationPreference.create as ReturnType<typeof vi.fn>
|
|
33
|
+
const mockedNotifCreate = prisma.notification.create as ReturnType<typeof vi.fn>
|
|
34
|
+
const mockedNotifFindFirst = prisma.notification.findFirst as ReturnType<typeof vi.fn>
|
|
35
|
+
const mockedNotifFindMany = prisma.notification.findMany as ReturnType<typeof vi.fn>
|
|
36
|
+
const mockedNotifCount = prisma.notification.count as ReturnType<typeof vi.fn>
|
|
37
|
+
const mockedEmit = notificationBus.emit as ReturnType<typeof vi.fn>
|
|
38
|
+
|
|
39
|
+
const realtimePrefs = {
|
|
40
|
+
userId: 'u1',
|
|
41
|
+
categories: {
|
|
42
|
+
team: 'realtime',
|
|
43
|
+
collaboration: 'realtime',
|
|
44
|
+
system: 'realtime',
|
|
45
|
+
billing: 'realtime',
|
|
46
|
+
security: 'realtime',
|
|
47
|
+
},
|
|
48
|
+
dndEnabled: false,
|
|
49
|
+
dndStart: '22:00',
|
|
50
|
+
dndEnd: '08:00',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const createdNotification = {
|
|
54
|
+
id: 'n1',
|
|
55
|
+
category: 'team',
|
|
56
|
+
type: 'invite',
|
|
57
|
+
title: 't',
|
|
58
|
+
body: null,
|
|
59
|
+
link: null,
|
|
60
|
+
readAt: null,
|
|
61
|
+
createdAt: new Date('2026-08-29T10:00:00Z'),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
mockedFindUnique.mockResolvedValue(realtimePrefs)
|
|
67
|
+
mockedCreatePref.mockResolvedValue(null)
|
|
68
|
+
mockedNotifCreate.mockResolvedValue(createdNotification)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('createNotification frequency 判定', () => {
|
|
72
|
+
it('off 类别完全不投递(不入库)', async () => {
|
|
73
|
+
mockedFindUnique.mockResolvedValue({
|
|
74
|
+
...realtimePrefs,
|
|
75
|
+
categories: { ...realtimePrefs.categories, team: 'off' },
|
|
76
|
+
})
|
|
77
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'invite', title: 't' })
|
|
78
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
79
|
+
expect(mockedEmit).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('realtime 类别入库并实时推送', async () => {
|
|
83
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'invite', title: 't' })
|
|
84
|
+
expect(mockedNotifCreate).toHaveBeenCalledTimes(1)
|
|
85
|
+
expect(mockedEmit).toHaveBeenCalledTimes(1)
|
|
86
|
+
expect(mockedEmit).toHaveBeenCalledWith('u1', {
|
|
87
|
+
kind: 'notification',
|
|
88
|
+
notification: expect.objectContaining({ id: 'n1' }),
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('digest-daily 类别仅入库不推送', async () => {
|
|
93
|
+
mockedFindUnique.mockResolvedValue({
|
|
94
|
+
...realtimePrefs,
|
|
95
|
+
categories: { ...realtimePrefs.categories, team: 'digest-daily' },
|
|
96
|
+
})
|
|
97
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'invite', title: 't' })
|
|
98
|
+
expect(mockedNotifCreate).toHaveBeenCalledTimes(1)
|
|
99
|
+
expect(mockedEmit).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('force 绕过 off 判定并强制推送', async () => {
|
|
103
|
+
mockedFindUnique.mockResolvedValue({
|
|
104
|
+
...realtimePrefs,
|
|
105
|
+
categories: { ...realtimePrefs.categories, team: 'off' },
|
|
106
|
+
})
|
|
107
|
+
await createNotification({ userId: 'u1', category: 'team', type: 'digest.daily', title: 't', force: true })
|
|
108
|
+
expect(mockedNotifCreate).toHaveBeenCalledTimes(1)
|
|
109
|
+
expect(mockedEmit).toHaveBeenCalledTimes(1)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('deliverDueDigests', () => {
|
|
114
|
+
const digestPrefs = {
|
|
115
|
+
userId: 'u1',
|
|
116
|
+
categories: {
|
|
117
|
+
team: 'digest-daily',
|
|
118
|
+
collaboration: 'realtime',
|
|
119
|
+
system: 'off',
|
|
120
|
+
billing: 'digest-weekly',
|
|
121
|
+
security: 'realtime',
|
|
122
|
+
},
|
|
123
|
+
dndEnabled: false,
|
|
124
|
+
dndStart: '22:00',
|
|
125
|
+
dndEnd: '08:00',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('到期且有积压时生成摘要并实时推送', async () => {
|
|
129
|
+
mockedFindUnique.mockResolvedValue(digestPrefs)
|
|
130
|
+
mockedNotifFindFirst.mockResolvedValue({
|
|
131
|
+
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
|
132
|
+
})
|
|
133
|
+
mockedNotifCount.mockResolvedValue(4)
|
|
134
|
+
mockedNotifFindMany.mockResolvedValue([{ title: 'a' }, { title: 'b' }, { title: 'c' }])
|
|
135
|
+
mockedNotifCreate.mockResolvedValue({ ...createdNotification, type: 'digest.daily' })
|
|
136
|
+
|
|
137
|
+
await deliverDueDigests('u1')
|
|
138
|
+
|
|
139
|
+
// team(daily) 到期生成摘要并推送;billing(weekly) 距水位线 25h < 7d 未到期
|
|
140
|
+
expect(mockedEmit).toHaveBeenCalledTimes(1)
|
|
141
|
+
const teamDigest = mockedNotifCreate.mock.calls.find(
|
|
142
|
+
(c) => c[0]?.data?.type === 'digest.daily'
|
|
143
|
+
)
|
|
144
|
+
expect(teamDigest).toBeDefined()
|
|
145
|
+
expect(teamDigest![0].data.title).toContain('今日团队协作有 4 条新通知')
|
|
146
|
+
expect(teamDigest![0].data.body).toContain('等 4 条通知')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('未到期(24h 内已有摘要)跳过', async () => {
|
|
150
|
+
mockedFindUnique.mockResolvedValue(digestPrefs)
|
|
151
|
+
mockedNotifFindFirst.mockResolvedValue({ createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000) })
|
|
152
|
+
|
|
153
|
+
await deliverDueDigests('u1')
|
|
154
|
+
|
|
155
|
+
expect(mockedNotifCount).not.toHaveBeenCalled()
|
|
156
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('到期但无积压通知跳过', async () => {
|
|
160
|
+
mockedFindUnique.mockResolvedValue(digestPrefs)
|
|
161
|
+
mockedNotifFindFirst.mockResolvedValue({
|
|
162
|
+
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
|
163
|
+
})
|
|
164
|
+
mockedNotifCount.mockResolvedValue(0)
|
|
165
|
+
|
|
166
|
+
await deliverDueDigests('u1')
|
|
167
|
+
|
|
168
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
169
|
+
expect(mockedEmit).not.toHaveBeenCalled()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('weekly 频率 7 天内已有摘要跳过', async () => {
|
|
173
|
+
mockedFindUnique.mockResolvedValue({
|
|
174
|
+
...digestPrefs,
|
|
175
|
+
categories: { ...digestPrefs.categories, billing: 'digest-weekly', team: 'realtime' },
|
|
176
|
+
})
|
|
177
|
+
mockedNotifFindFirst.mockResolvedValue({ createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000) })
|
|
178
|
+
|
|
179
|
+
await deliverDueDigests('u1')
|
|
180
|
+
|
|
181
|
+
expect(mockedNotifCount).not.toHaveBeenCalled()
|
|
182
|
+
expect(mockedNotifCreate).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('无 digest 类别直接返回', async () => {
|
|
186
|
+
await deliverDueDigests('u1')
|
|
187
|
+
expect(mockedNotifFindFirst).not.toHaveBeenCalled()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
import { prisma } from '@allbluecn/database'
|
|
3
3
|
import type {
|
|
4
|
-
|
|
4
|
+
CategoryFrequencies,
|
|
5
5
|
NotificationCategory,
|
|
6
6
|
NotificationItem,
|
|
7
7
|
NotificationPreferences,
|
|
8
8
|
} from '@allbluecn/shared'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isInDndWindow,
|
|
11
|
+
normalizeCategoryFrequencies,
|
|
12
|
+
DEFAULT_CATEGORIES,
|
|
13
|
+
} from '@allbluecn/shared'
|
|
10
14
|
import { notificationBus } from './event-bus'
|
|
11
15
|
|
|
12
|
-
export { DEFAULT_CATEGORIES, isInDndWindow } from '@allbluecn/shared'
|
|
16
|
+
export { DEFAULT_CATEGORIES, isInDndWindow, normalizeCategoryFrequencies } from '@allbluecn/shared'
|
|
13
17
|
|
|
14
18
|
export interface CreateNotificationInput {
|
|
15
19
|
userId: string
|
|
@@ -18,13 +22,36 @@ export interface CreateNotificationInput {
|
|
|
18
22
|
title: string
|
|
19
23
|
body?: string
|
|
20
24
|
link?: string
|
|
25
|
+
/** 绕过 frequency 判定并强制实时推送(仅 digest 摘要生成使用) */
|
|
26
|
+
force?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 服务端直出的类别中文名(与 zh locale tabs 保持一致) */
|
|
30
|
+
const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
|
31
|
+
team: '团队协作',
|
|
32
|
+
collaboration: '内容协作',
|
|
33
|
+
system: '系统公告',
|
|
34
|
+
billing: '账单订阅',
|
|
35
|
+
security: '安全告警',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DIGEST_TYPE: Record<'digest-daily' | 'digest-weekly', string> = {
|
|
39
|
+
'digest-daily': 'digest.daily',
|
|
40
|
+
'digest-weekly': 'digest.weekly',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DIGEST_MIN_INTERVAL_MS: Record<'digest-daily' | 'digest-weekly', number> = {
|
|
44
|
+
'digest-daily': 24 * 60 * 60 * 1000,
|
|
45
|
+
'digest-weekly': 7 * 24 * 60 * 60 * 1000,
|
|
21
46
|
}
|
|
22
47
|
|
|
48
|
+
const DIGEST_SUMMARY_ITEMS = 3
|
|
49
|
+
|
|
23
50
|
export async function getOrCreatePreferences(userId: string): Promise<NotificationPreferences> {
|
|
24
51
|
const existing = await prisma.notificationPreference.findUnique({ where: { userId } })
|
|
25
52
|
if (existing) {
|
|
26
53
|
return {
|
|
27
|
-
categories:
|
|
54
|
+
categories: normalizeCategoryFrequencies(existing.categories),
|
|
28
55
|
dndEnabled: existing.dndEnabled,
|
|
29
56
|
dndStart: existing.dndStart,
|
|
30
57
|
dndEnd: existing.dndEnd,
|
|
@@ -52,35 +79,118 @@ export async function getOrCreatePreferences(userId: string): Promise<Notificati
|
|
|
52
79
|
return defaults
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
async function insertNotification(
|
|
83
|
+
input: CreateNotificationInput
|
|
84
|
+
): Promise<NotificationItem | null> {
|
|
85
|
+
const created = await prisma.notification.create({
|
|
86
|
+
data: {
|
|
87
|
+
userId: input.userId,
|
|
88
|
+
category: input.category,
|
|
89
|
+
type: input.type,
|
|
90
|
+
title: input.title,
|
|
91
|
+
body: input.body,
|
|
92
|
+
link: input.link,
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
return {
|
|
96
|
+
id: created.id,
|
|
97
|
+
category: created.category as NotificationCategory,
|
|
98
|
+
type: created.type,
|
|
99
|
+
title: created.title,
|
|
100
|
+
body: created.body,
|
|
101
|
+
link: created.link,
|
|
102
|
+
readAt: created.readAt ? created.readAt.toISOString() : null,
|
|
103
|
+
createdAt: created.createdAt.toISOString(),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* fire-and-forget:任何失败只记日志不抛出,不阻断业务主流程。
|
|
109
|
+
* frequency 判定:off 不投递;realtime 入库 + 实时推送;digest-* 仅入库(由 deliverDueDigests 汇总推送)。
|
|
110
|
+
*/
|
|
56
111
|
export async function createNotification(input: CreateNotificationInput): Promise<void> {
|
|
57
112
|
try {
|
|
58
113
|
const prefs = await getOrCreatePreferences(input.userId)
|
|
59
|
-
|
|
114
|
+
const frequency = prefs.categories[input.category]
|
|
115
|
+
if (!input.force && frequency === 'off') return
|
|
60
116
|
|
|
61
|
-
const
|
|
62
|
-
|
|
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
|
-
})
|
|
117
|
+
const item = await insertNotification(input)
|
|
118
|
+
if (!item) return
|
|
71
119
|
|
|
72
|
-
|
|
73
|
-
|
|
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(),
|
|
120
|
+
if (input.force || frequency === 'realtime') {
|
|
121
|
+
notificationBus.emit(input.userId, { kind: 'notification', notification: item })
|
|
81
122
|
}
|
|
82
|
-
notificationBus.emit(input.userId, { kind: 'notification', notification: item })
|
|
83
123
|
} catch (e) {
|
|
84
124
|
console.error('[notifications] create failed:', e instanceof Error ? e.message : 'unknown')
|
|
85
125
|
}
|
|
86
|
-
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatDigestBody(titles: string[], total: number): string {
|
|
129
|
+
const lines = titles.slice(0, DIGEST_SUMMARY_ITEMS).map((t) => `· ${t}`)
|
|
130
|
+
if (total > DIGEST_SUMMARY_ITEMS) {
|
|
131
|
+
lines.push(`等 ${total} 条通知`)
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 检查并投递到期的 digest(摘要)通知。在用户建立 SSE 连接时调用(fire-and-forget):
|
|
138
|
+
* 以同类别最近一条同频率摘要的 createdAt 为水位线,积压通知数 > 0 且距上次摘要超过
|
|
139
|
+
* 频率周期时,按类别生成一条摘要通知并实时推送。
|
|
140
|
+
*/
|
|
141
|
+
export async function deliverDueDigests(userId: string): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
const prefs = await getOrCreatePreferences(userId)
|
|
144
|
+
const digestCats = (Object.keys(prefs.categories) as NotificationCategory[]).filter(
|
|
145
|
+
(c) => prefs.categories[c] === 'digest-daily' || prefs.categories[c] === 'digest-weekly'
|
|
146
|
+
)
|
|
147
|
+
for (const category of digestCats) {
|
|
148
|
+
const frequency = prefs.categories[category] as 'digest-daily' | 'digest-weekly'
|
|
149
|
+
const type = DIGEST_TYPE[frequency]
|
|
150
|
+
|
|
151
|
+
const lastDigest = await prisma.notification.findFirst({
|
|
152
|
+
where: { userId, category, type },
|
|
153
|
+
orderBy: { createdAt: 'desc' },
|
|
154
|
+
select: { createdAt: true },
|
|
155
|
+
})
|
|
156
|
+
if (lastDigest && Date.now() - lastDigest.createdAt.getTime() < DIGEST_MIN_INTERVAL_MS[frequency]) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const since = lastDigest?.createdAt ?? new Date(0)
|
|
161
|
+
const pendingCount = await prisma.notification.count({
|
|
162
|
+
where: {
|
|
163
|
+
userId,
|
|
164
|
+
category,
|
|
165
|
+
type: { not: { startsWith: 'digest.' } },
|
|
166
|
+
createdAt: { gt: since },
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
if (pendingCount === 0) continue
|
|
170
|
+
|
|
171
|
+
const pending = await prisma.notification.findMany({
|
|
172
|
+
where: {
|
|
173
|
+
userId,
|
|
174
|
+
category,
|
|
175
|
+
type: { not: { startsWith: 'digest.' } },
|
|
176
|
+
createdAt: { gt: since },
|
|
177
|
+
},
|
|
178
|
+
orderBy: { createdAt: 'desc' },
|
|
179
|
+
select: { title: true },
|
|
180
|
+
take: DIGEST_SUMMARY_ITEMS,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const periodLabel = frequency === 'digest-daily' ? '今日' : '本周'
|
|
184
|
+
await createNotification({
|
|
185
|
+
userId,
|
|
186
|
+
category,
|
|
187
|
+
type,
|
|
188
|
+
title: `${periodLabel}${CATEGORY_LABELS[category]}有 ${pendingCount} 条新通知`,
|
|
189
|
+
body: formatDigestBody(pending.map((n) => n.title), pendingCount),
|
|
190
|
+
force: true,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error('[notifications] digest failed:', e instanceof Error ? e.message : 'unknown')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -40,8 +40,9 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
40
40
|
update: vi.fn(),
|
|
41
41
|
count: vi.fn(),
|
|
42
42
|
deleteMany: vi.fn(),
|
|
43
|
+
delete: vi.fn(),
|
|
43
44
|
},
|
|
44
|
-
teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn() },
|
|
45
|
+
teamJoinRequest: { updateMany: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
|
|
45
46
|
$transaction: vi.fn(),
|
|
46
47
|
},
|
|
47
48
|
}))
|
|
@@ -68,16 +69,18 @@ import {
|
|
|
68
69
|
refreshTeamInvitationLinkFn,
|
|
69
70
|
listTeamInvitationsFn,
|
|
70
71
|
clearExpiredInvitationsFn,
|
|
72
|
+
deleteTeamInvitationFn,
|
|
73
|
+
deleteTeamJoinRequestFn,
|
|
71
74
|
} from '../team'
|
|
72
75
|
|
|
73
76
|
describe('refreshTeamInvitationLinkFn', () => {
|
|
74
77
|
beforeEach(() => vi.clearAllMocks())
|
|
75
78
|
|
|
76
|
-
it('
|
|
79
|
+
it('生成新链接,旧链接置 revoked 保留申请历史', async () => {
|
|
77
80
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
78
81
|
const tx = {
|
|
79
82
|
teamInvitation: {
|
|
80
|
-
|
|
83
|
+
updateMany: vi.fn().mockResolvedValue({}),
|
|
81
84
|
create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'abc123' }),
|
|
82
85
|
},
|
|
83
86
|
teamJoinRequest: {
|
|
@@ -97,7 +100,7 @@ describe('refreshTeamInvitationLinkFn', () => {
|
|
|
97
100
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
98
101
|
const tx = {
|
|
99
102
|
teamInvitation: {
|
|
100
|
-
|
|
103
|
+
updateMany: vi.fn().mockResolvedValue({}),
|
|
101
104
|
create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'xyz' }),
|
|
102
105
|
},
|
|
103
106
|
teamJoinRequest: {
|
|
@@ -114,7 +117,7 @@ describe('refreshTeamInvitationLinkFn', () => {
|
|
|
114
117
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
|
|
115
118
|
const tx = {
|
|
116
119
|
teamInvitation: {
|
|
117
|
-
|
|
120
|
+
updateMany: vi.fn().mockResolvedValue({}),
|
|
118
121
|
create: vi.fn().mockResolvedValue({ id: 'inv-new', token: 'tok' }),
|
|
119
122
|
},
|
|
120
123
|
teamJoinRequest: {
|
|
@@ -199,15 +202,78 @@ describe('createTeamInvitationFn link 邀请', () => {
|
|
|
199
202
|
describe('clearExpiredInvitationsFn', () => {
|
|
200
203
|
beforeEach(() => vi.clearAllMocks())
|
|
201
204
|
|
|
202
|
-
it('
|
|
205
|
+
it('清除所有过期邮箱邀请(链接行保留以维持申请历史)', async () => {
|
|
203
206
|
vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 2 } as never)
|
|
204
207
|
const result = await clearExpiredInvitationsFn({ data: {} })
|
|
205
208
|
expect(result.success).toBe(true)
|
|
206
209
|
expect(result.count).toBe(2)
|
|
207
210
|
expect(prisma.teamInvitation.deleteMany).toHaveBeenCalledWith(
|
|
208
211
|
expect.objectContaining({
|
|
209
|
-
where: expect.objectContaining({ status: 'expired' }),
|
|
212
|
+
where: expect.objectContaining({ status: 'expired', channel: 'email' }),
|
|
210
213
|
}),
|
|
211
214
|
)
|
|
212
215
|
})
|
|
213
216
|
})
|
|
217
|
+
|
|
218
|
+
describe('deleteTeamInvitationFn', () => {
|
|
219
|
+
beforeEach(() => vi.clearAllMocks())
|
|
220
|
+
|
|
221
|
+
it('删除自己的 email 邀请记录', async () => {
|
|
222
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
|
|
223
|
+
id: 'inv1', ownerId: 'owner1', channel: 'email', email: 'x@y.com',
|
|
224
|
+
} as never)
|
|
225
|
+
vi.mocked(prisma.teamInvitation.delete).mockResolvedValue({} as never)
|
|
226
|
+
|
|
227
|
+
const result = await deleteTeamInvitationFn({ data: { invitationId: 'inv1' } })
|
|
228
|
+
|
|
229
|
+
expect(result.success).toBe(true)
|
|
230
|
+
expect(prisma.teamInvitation.delete).toHaveBeenCalledWith({ where: { id: 'inv1' } })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('link 渠道邀请禁止删除(防级联删申请)', async () => {
|
|
234
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
|
|
235
|
+
id: 'inv2', ownerId: 'owner1', channel: 'link',
|
|
236
|
+
} as never)
|
|
237
|
+
|
|
238
|
+
await expect(
|
|
239
|
+
deleteTeamInvitationFn({ data: { invitationId: 'inv2' } }),
|
|
240
|
+
).rejects.toMatchObject({ message: expect.stringContaining('不可删除') })
|
|
241
|
+
expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('他人邀请不可删除', async () => {
|
|
245
|
+
vi.mocked(prisma.teamInvitation.findUnique).mockResolvedValue({
|
|
246
|
+
id: 'inv3', ownerId: 'someone-else', channel: 'email',
|
|
247
|
+
} as never)
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
deleteTeamInvitationFn({ data: { invitationId: 'inv3' } }),
|
|
251
|
+
).rejects.toMatchObject({ message: '邀请不存在' })
|
|
252
|
+
expect(prisma.teamInvitation.delete).not.toHaveBeenCalled()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('deleteTeamJoinRequestFn', () => {
|
|
257
|
+
beforeEach(() => vi.clearAllMocks())
|
|
258
|
+
|
|
259
|
+
it('删除自己的申请记录', async () => {
|
|
260
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue({
|
|
261
|
+
id: 'jr1', ownerId: 'owner1', email: 'a@b.com', status: 'rejected',
|
|
262
|
+
} as never)
|
|
263
|
+
vi.mocked(prisma.teamJoinRequest.delete).mockResolvedValue({} as never)
|
|
264
|
+
|
|
265
|
+
const result = await deleteTeamJoinRequestFn({ data: { requestId: 'jr1' } })
|
|
266
|
+
|
|
267
|
+
expect(result.success).toBe(true)
|
|
268
|
+
expect(prisma.teamJoinRequest.delete).toHaveBeenCalledWith({ where: { id: 'jr1' } })
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('他人申请不可删除', async () => {
|
|
272
|
+
vi.mocked(prisma.teamJoinRequest.findUnique).mockResolvedValue(null as never)
|
|
273
|
+
|
|
274
|
+
await expect(
|
|
275
|
+
deleteTeamJoinRequestFn({ data: { requestId: 'jrX' } }),
|
|
276
|
+
).rejects.toMatchObject({ message: '申请不存在' })
|
|
277
|
+
expect(prisma.teamJoinRequest.delete).not.toHaveBeenCalled()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
@@ -246,9 +246,9 @@ describe('getTeamInvitationByTokenFn(link 渠道)', () => {
|
|
|
246
246
|
describe('refreshTeamInvitationLinkFn', () => {
|
|
247
247
|
beforeEach(() => vi.clearAllMocks())
|
|
248
248
|
|
|
249
|
-
it('刷新 → 旧链接 pending 申请全部 invalidated
|
|
249
|
+
it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接置 revoked 保留历史,新链接创建', async () => {
|
|
250
250
|
vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
|
|
251
|
-
vi.mocked(prisma.teamInvitation.
|
|
251
|
+
vi.mocked(prisma.teamInvitation.updateMany).mockResolvedValue({ count: 1 } as never)
|
|
252
252
|
vi.mocked(prisma.teamInvitation.create).mockResolvedValue({ id: 'inv2', token: 'NEW' } as never)
|
|
253
253
|
|
|
254
254
|
const res = await refreshTeamInvitationLinkFn({ data: { expiresInDays: 7 } })
|
|
@@ -258,9 +258,11 @@ describe('refreshTeamInvitationLinkFn', () => {
|
|
|
258
258
|
where: expect.objectContaining({ ownerId: 'u2', status: 'pending' }),
|
|
259
259
|
data: expect.objectContaining({ status: 'invalidated' }),
|
|
260
260
|
}))
|
|
261
|
-
expect(prisma.teamInvitation.
|
|
261
|
+
expect(prisma.teamInvitation.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
262
262
|
where: { ownerId: 'u2', channel: 'link', status: { in: ['pending', 'pending_approval'] } },
|
|
263
|
+
data: { status: 'revoked' },
|
|
263
264
|
}))
|
|
265
|
+
expect(prisma.teamInvitation.deleteMany).not.toHaveBeenCalled()
|
|
264
266
|
expect(prisma.teamInvitation.create).toHaveBeenCalled()
|
|
265
267
|
})
|
|
266
268
|
})
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
-
import { getRequest } from '@tanstack/react-start/server'
|
|
2
|
+
import { getRequest, setCookie } from '@tanstack/react-start/server'
|
|
3
3
|
import { detectLocale } from '@/lib/i18n/detect'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* 检测用户语言并固化为 lng cookie:
|
|
7
|
+
* 首次访问(无 cookie)时,SSR 依据 querystring/accept-language 检测出的语言
|
|
8
|
+
* 写入 cookie,保证客户端 i18n(仅读 querystring/cookie)与服务端同源一致,
|
|
9
|
+
* 避免 hydration mismatch。
|
|
10
|
+
*/
|
|
5
11
|
export const getLocaleFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
6
12
|
const request = getRequest()
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
const locale = detectLocale(request, null)
|
|
14
|
+
setCookie('lng', locale, {
|
|
15
|
+
path: '/',
|
|
16
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
17
|
+
sameSite: 'lax',
|
|
18
|
+
})
|
|
19
|
+
return locale
|
|
20
|
+
})
|
|
@@ -6,10 +6,11 @@ import { prisma } from '@allbluecn/database'
|
|
|
6
6
|
import { requireAuth } from '@/server/auth-helpers'
|
|
7
7
|
import {
|
|
8
8
|
NOTIFICATION_CATEGORIES,
|
|
9
|
+
NOTIFICATION_FREQUENCIES,
|
|
9
10
|
type NotificationCategory,
|
|
10
|
-
type CategoryPreferences,
|
|
11
11
|
type NotificationItem,
|
|
12
12
|
type NotificationListResult,
|
|
13
|
+
type CategoryFrequencies,
|
|
13
14
|
} from '@allbluecn/shared'
|
|
14
15
|
import {
|
|
15
16
|
getOrCreatePreferences,
|
|
@@ -96,6 +97,16 @@ export const deleteNotificationFn = createServerFn({ method: 'POST' })
|
|
|
96
97
|
return { success: true }
|
|
97
98
|
})
|
|
98
99
|
|
|
100
|
+
export const clearNotificationsFn = createServerFn({ method: 'POST' })
|
|
101
|
+
.validator(z.object({}).optional())
|
|
102
|
+
.handler(async () => {
|
|
103
|
+
const request = getRequest()
|
|
104
|
+
const session = await requireAuth(request)
|
|
105
|
+
const userId = session.user!.id!
|
|
106
|
+
const result = await prisma.notification.deleteMany({ where: { userId } })
|
|
107
|
+
return { success: true, count: result.count }
|
|
108
|
+
})
|
|
109
|
+
|
|
99
110
|
export const getUnreadCountFn = createServerFn({ method: 'GET' })
|
|
100
111
|
.validator(z.object({}).optional())
|
|
101
112
|
.handler(async () => {
|
|
@@ -117,7 +128,7 @@ export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
|
|
|
117
128
|
})
|
|
118
129
|
|
|
119
130
|
const preferencesSchema = z.object({
|
|
120
|
-
categories: z.record(z.string(), z.
|
|
131
|
+
categories: z.record(z.string(), z.enum(NOTIFICATION_FREQUENCIES)),
|
|
121
132
|
dndEnabled: z.boolean(),
|
|
122
133
|
dndStart: z.string().regex(/^\d{2}:\d{2}$/),
|
|
123
134
|
dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
|
|
@@ -131,9 +142,12 @@ export const updateNotificationPreferencesFn = createServerFn({
|
|
|
131
142
|
const request = getRequest()
|
|
132
143
|
const session = await requireAuth(request)
|
|
133
144
|
const userId = session.user!.id!
|
|
134
|
-
const categories = { ...DEFAULT_CATEGORIES } as
|
|
145
|
+
const categories = { ...DEFAULT_CATEGORIES } as CategoryFrequencies
|
|
135
146
|
for (const c of NOTIFICATION_CATEGORIES) {
|
|
136
|
-
|
|
147
|
+
const v = data.categories[c]
|
|
148
|
+
if ((NOTIFICATION_FREQUENCIES as readonly string[]).includes(v)) {
|
|
149
|
+
categories[c] = v as CategoryFrequencies[NotificationCategory]
|
|
150
|
+
}
|
|
137
151
|
}
|
|
138
152
|
await prisma.notificationPreference.upsert({
|
|
139
153
|
where: { userId },
|