@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.
Files changed (70) hide show
  1. package/package.json +8 -8
  2. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  3. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  4. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  5. package/src/components/notifications/settings-notifications.tsx +198 -0
  6. package/src/components/notifications/team-notifications.tsx +343 -0
  7. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  8. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  9. package/src/components/shared/wave-physics-loader.tsx +155 -0
  10. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  11. package/src/components/story/constants.ts +114 -51
  12. package/src/components/story/detail/story-detail-body.tsx +13 -3
  13. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  14. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  15. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  16. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  17. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  18. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  19. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  20. package/src/components/story/peek-panel.tsx +8 -1
  21. package/src/components/story/relations/story-ref-row.tsx +319 -0
  22. package/src/components/story/story-list-view.tsx +3 -1
  23. package/src/components/story/story-panel-header.tsx +4 -1
  24. package/src/components/story/types.ts +17 -18
  25. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  26. package/src/components/story/views/story-list/index.tsx +16 -5
  27. package/src/components/story/views/story-list/row.tsx +5 -4
  28. package/src/components/ui/separator.tsx +1 -1
  29. package/src/hooks/use-notifications.ts +4 -1
  30. package/src/lib/__tests__/datetime.test.ts +16 -1
  31. package/src/lib/changelog/sdk-versions.ts +19 -19
  32. package/src/lib/datetime.ts +1 -0
  33. package/src/routeTree.gen.ts +23 -0
  34. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  35. package/src/routes/_nav/notifications/route.tsx +30 -0
  36. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  37. package/src/routes/api/notifications/stream.ts +4 -0
  38. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  39. package/src/server/notifications/notification-service.ts +137 -27
  40. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  41. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  42. package/src/server/serverFns/locale.ts +15 -3
  43. package/src/server/serverFns/notifications.ts +18 -4
  44. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  45. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  46. package/src/server/serverFns/story/index.ts +1 -2
  47. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  48. package/src/server/serverFns/story/story-groups.ts +23 -3
  49. package/src/server/serverFns/story/story-transfer.ts +5 -3
  50. package/src/server/serverFns/team.ts +51 -2
  51. package/src/components/notifications/notification-list.tsx +0 -232
  52. package/src/components/notifications/notification-preferences.tsx +0 -156
  53. package/src/components/story/relation-badge.tsx +0 -57
  54. package/src/components/story/relations/dependency-panel.tsx +0 -190
  55. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  56. package/src/components/story/relations/tab.tsx +0 -212
  57. package/src/config/sidebar-notifications.ts +0 -50
  58. package/src/plugins/.gen-manifest.json +0 -13
  59. package/src/plugins/modules.gen.ts +0 -20
  60. package/src/plugins/plugins.gen.ts +0 -20
  61. package/src/plugins/ui.gen.ts +0 -8
  62. package/src/routes/_nav/inspiration/route.tsx +0 -8
  63. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  64. package/src/routes/_nav/prd/index.tsx +0 -9
  65. package/src/routes/_nav/prd/route.tsx +0 -8
  66. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  67. package/src/routes/_nav/tags/index.tsx +0 -8
  68. package/src/routes/_nav/tags/route.tsx +0 -8
  69. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  70. 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
- CategoryPreferences,
4
+ CategoryFrequencies,
5
5
  NotificationCategory,
6
6
  NotificationItem,
7
7
  NotificationPreferences,
8
8
  } from '@allbluecn/shared'
9
- import { isInDndWindow, DEFAULT_CATEGORIES } from '@allbluecn/shared'
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: { ...DEFAULT_CATEGORIES, ...(existing.categories as object) } as CategoryPreferences,
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
- /** fire-and-forget:任何失败只记日志不抛出,不阻断业务主流程 */
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
- if (!prefs.categories[input.category]) return
114
+ const frequency = prefs.categories[input.category]
115
+ if (!input.force && frequency === 'off') return
60
116
 
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
- })
117
+ const item = await insertNotification(input)
118
+ if (!item) return
71
119
 
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(),
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('生成新链接,删除旧链接', async () => {
79
+ it('生成新链接,旧链接置 revoked 保留申请历史', async () => {
77
80
  vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
78
81
  const tx = {
79
82
  teamInvitation: {
80
- deleteMany: vi.fn().mockResolvedValue({}),
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
- deleteMany: vi.fn().mockResolvedValue({}),
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
- deleteMany: vi.fn().mockResolvedValue({}),
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('清除所有过期邀请', async () => {
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,旧链接删除,新链接创建', async () => {
249
+ it('刷新 → 旧链接 pending 申请全部 invalidated,旧链接置 revoked 保留历史,新链接创建', async () => {
250
250
  vi.mocked(prisma.teamJoinRequest.updateMany).mockResolvedValue({ count: 1 } as never)
251
- vi.mocked(prisma.teamInvitation.deleteMany).mockResolvedValue({ count: 1 } as never)
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.deleteMany).toHaveBeenCalledWith(expect.objectContaining({
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
- return detectLocale(request, null)
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.boolean()),
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 CategoryPreferences
145
+ const categories = { ...DEFAULT_CATEGORIES } as CategoryFrequencies
135
146
  for (const c of NOTIFICATION_CATEGORIES) {
136
- if (typeof data.categories[c] === 'boolean') categories[c] = data.categories[c]
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 },