@allbluecn/web-app 0.4.18 → 0.4.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -136,13 +136,13 @@
136
136
  "tw-animate-css": "^1.4.0",
137
137
  "zod": "^4.4.3",
138
138
  "@allbluecn/kernel": "0.3.0",
139
- "@allbluecn/database": "0.4.13",
140
- "@allbluecn/ui": "0.4.13",
141
- "@allbluecn/shared": "0.4.13",
142
- "@allbluecn/ai-gateway": "0.4.13",
139
+ "@allbluecn/ai-gateway": "0.4.14",
140
+ "@allbluecn/database": "0.4.14",
141
+ "@allbluecn/plugins-core": "0.4.13",
143
142
  "@allbluecn/prototype": "0.1.1",
144
- "@allbluecn/tiptap": "0.4.12",
145
- "@allbluecn/plugins-core": "0.4.12"
143
+ "@allbluecn/shared": "0.4.14",
144
+ "@allbluecn/ui": "0.4.14",
145
+ "@allbluecn/tiptap": "0.4.13"
146
146
  },
147
147
  "devDependencies": {
148
148
  "@babel/parser": "^8.0.4",
@@ -37,6 +37,7 @@ import { ThemeToggle } from "@/components/shared/theme-toggle";
37
37
  import { AnimatedThemeToggler } from '@/components/magicui/animated-theme-toggler'
38
38
  import { Badge } from "@/components/ui/badge"
39
39
  import { workspaceRoutes } from "@/config/sidebar-routes";
40
+ import { notifications } from "@/config/sidebar-notifications";
40
41
  import type { Route } from "@/config/sidebar-routes";
41
42
  import { SidebarSearchTrigger } from "@/components/layout/sidebar-03/nav-search-trigger"
42
43
  import { NavSecondary } from "@/components/layout/sidebar-03/nav-secondary"
@@ -64,7 +65,7 @@ export function DashboardSidebar() {
64
65
  animate={{ opacity: 1 }}
65
66
  transition={{ duration: 0.8 }}
66
67
  >
67
- <NotificationsPopover />
68
+ <NotificationsPopover notifications={notifications} />
68
69
  <SidebarTrigger />
69
70
  </m.div>
70
71
  </div>
@@ -85,7 +86,7 @@ export function DashboardSidebar() {
85
86
  animate={{ opacity: 1 }}
86
87
  transition={{ duration: 0.8 }}
87
88
  >
88
- <NotificationsPopover />
89
+ <NotificationsPopover notifications={notifications} />
89
90
  <AnimatedThemeToggler />
90
91
 
91
92
  <SidebarTrigger />
@@ -16,12 +16,8 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
 
19
- import { useNavigate } from '@tanstack/react-router'
20
- import { useQuery, useQueryClient } from '@tanstack/react-query'
21
- import { useTranslation } from 'react-i18next'
22
- import { BellIcon, CheckCheck } from 'lucide-react'
23
- import { Avatar, AvatarFallback } from '@/components/ui/avatar'
24
- import { Button } from '@/components/ui/button'
19
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
20
+ import { Button } from "@/components/ui/button";
25
21
  import {
26
22
  DropdownMenu,
27
23
  DropdownMenuContent,
@@ -29,130 +25,54 @@ import {
29
25
  DropdownMenuLabel,
30
26
  DropdownMenuSeparator,
31
27
  DropdownMenuTrigger,
32
- } from '@/components/ui/dropdown-menu'
33
- import {
34
- listNotificationsFn,
35
- markAllNotificationsReadFn,
36
- markNotificationReadFn,
37
- } from '@/server/serverFns/notifications'
38
- import { NOTIFICATION_LIST_KEY, NOTIFICATION_UNREAD_KEY, useNotifications } from '@/hooks/use-notifications'
39
- import { CATEGORY_ICONS } from '@/components/notifications/notification-list'
40
-
41
- export function NotificationsPopover() {
42
- const { t } = useTranslation('notifications')
43
- const navigate = useNavigate()
44
- const queryClient = useQueryClient()
45
- const { unreadCount, setUnreadCount } = useNotifications()
46
-
47
- const { data } = useQuery({
48
- queryKey: NOTIFICATION_LIST_KEY,
49
- queryFn: () => listNotificationsFn({ data: {} }),
50
- })
28
+ } from "@/components/ui/dropdown-menu";
29
+ import { BellIcon } from "lucide-react";
51
30
 
52
- const recent = (data?.items ?? []).slice(0, 5)
53
-
54
- const handleClickItem = async (
55
- id: string,
56
- link: string | null,
57
- isUnread: boolean,
58
- ) => {
59
- if (isUnread) {
60
- await markNotificationReadFn({ data: { id } })
61
- setUnreadCount((c) => Math.max(0, c - 1))
62
- queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
63
- queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
64
- }
65
- if (link) {
66
- navigate({ to: link as never })
67
- }
68
- }
69
-
70
- const handleMarkAll = async () => {
71
- await markAllNotificationsReadFn({ data: {} })
72
- setUnreadCount(0)
73
- queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
74
- queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
75
- }
31
+ type Notification = {
32
+ id: string;
33
+ avatar: string;
34
+ fallback: string;
35
+ text: string;
36
+ time: string;
37
+ };
76
38
 
39
+ export function NotificationsPopover({
40
+ notifications,
41
+ }: {
42
+ notifications: Notification[];
43
+ }) {
77
44
  return (
78
45
  <DropdownMenu>
79
46
  <DropdownMenuTrigger asChild>
80
47
  <Button
81
48
  variant="ghost"
82
49
  size="icon"
83
- className="relative rounded-full"
84
- aria-label={t('bell.label')}
50
+ className="rounded-full"
51
+ aria-label="Open notifications"
85
52
  >
86
53
  <BellIcon className="size-5" />
87
- {unreadCount > 0 && (
88
- <span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
89
- {unreadCount > 99 ? '99+' : unreadCount}
90
- </span>
91
- )}
92
54
  </Button>
93
55
  </DropdownMenuTrigger>
94
56
  <DropdownMenuContent side="right" className="w-80 my-6">
95
- <div className="flex items-center justify-between px-2 py-1.5">
96
- <DropdownMenuLabel className="p-0">{t('bell.recentTitle')}</DropdownMenuLabel>
97
- {unreadCount > 0 && (
98
- <Button
99
- variant="ghost"
100
- size="sm"
101
- className="h-7 gap-1 px-2 text-xs"
102
- onClick={handleMarkAll}
103
- >
104
- <CheckCheck className="size-3.5" />
105
- {t('actions.markAllRead')}
106
- </Button>
107
- )}
108
- </div>
57
+ <DropdownMenuLabel>Notifications</DropdownMenuLabel>
109
58
  <DropdownMenuSeparator />
110
- {recent.length === 0 && (
111
- <div className="px-2 py-6 text-center text-sm text-muted-foreground">
112
- {t('empty')}
113
- </div>
114
- )}
115
- {recent.map((item) => {
116
- const Icon = CATEGORY_ICONS[item.category]
117
- const isUnread = !item.readAt
118
- return (
119
- <DropdownMenuItem
120
- key={item.id}
121
- className="flex items-start gap-3"
122
- onClick={() => handleClickItem(item.id, item.link, isUnread)}
123
- >
124
- <Avatar className="size-8">
125
- <AvatarFallback className="bg-primary/10 text-primary">
126
- <Icon className="size-4" />
127
- </AvatarFallback>
128
- </Avatar>
129
- <div className="flex min-w-0 flex-col">
130
- <span
131
- className={
132
- isUnread
133
- ? 'text-sm font-medium'
134
- : 'text-sm font-medium opacity-60'
135
- }
136
- >
137
- {item.title}
138
- </span>
139
- {item.body && (
140
- <span className="line-clamp-1 text-xs text-muted-foreground">
141
- {item.body}
142
- </span>
143
- )}
144
- </div>
145
- </DropdownMenuItem>
146
- )
147
- })}
59
+ {notifications.map(({ id, avatar, fallback, text, time }) => (
60
+ <DropdownMenuItem key={id} className="flex items-start gap-3">
61
+ <Avatar className="size-8">
62
+ <AvatarImage src={avatar} alt="Avatar" />
63
+ <AvatarFallback>{fallback}</AvatarFallback>
64
+ </Avatar>
65
+ <div className="flex flex-col">
66
+ <span className="text-sm font-medium">{text}</span>
67
+ <span className="text-xs text-muted-foreground">{time}</span>
68
+ </div>
69
+ </DropdownMenuItem>
70
+ ))}
148
71
  <DropdownMenuSeparator />
149
- <DropdownMenuItem
150
- className="justify-center text-sm text-muted-foreground hover:text-primary"
151
- onClick={() => navigate({ to: '/notifications' })}
152
- >
153
- {t('actions.viewAll')}
72
+ <DropdownMenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
73
+ View all notifications
154
74
  </DropdownMenuItem>
155
75
  </DropdownMenuContent>
156
76
  </DropdownMenu>
157
- )
158
- }
77
+ );
78
+ }
@@ -19,7 +19,9 @@ import type React from "react";
19
19
  import {
20
20
  Sparkles,
21
21
  BotMessageSquare,
22
+ Lightbulb,
22
23
  ClipboardList,
24
+ Package2,
23
25
  FlaskConical,
24
26
  Bug,
25
27
  CircleCheckBig,
@@ -59,6 +61,13 @@ export const workspaceRoutes: Route[] = [
59
61
  badge: " ",
60
62
  link: "/chat",
61
63
  },
64
+ {
65
+ id: "inspiration",
66
+ title: "nav.inspiration",
67
+ icon: <Lightbulb className="size-4" />,
68
+ badge: " ",
69
+ link: "/inspiration",
70
+ },
62
71
  {
63
72
  id: "story",
64
73
  title: "nav.story",
@@ -66,6 +75,13 @@ export const workspaceRoutes: Route[] = [
66
75
  badge: " ",
67
76
  link: "/stories",
68
77
  },
78
+ {
79
+ id: "prd",
80
+ title: "nav.prd",
81
+ icon: <Package2 className="size-4" />,
82
+ badge: "原型画板",
83
+ link: "/prd",
84
+ },
69
85
  {
70
86
  id: "design",
71
87
  title: "nav.design",
@@ -139,8 +139,8 @@ export const authConfig: StartAuthJSConfig = {
139
139
  // 安全提取 IP/UA(start-authjs 传递的 request 类型可能不标准,失败不阻塞登录)
140
140
  let ip: string | null = null
141
141
  let userAgent: string | null = null
142
- const headers = request?.headers as { get?: (name: string) => string | null } | undefined
143
142
  try {
143
+ const headers = request?.headers as { get?: (name: string) => string | null } | undefined
144
144
  if (headers && typeof headers.get === 'function') {
145
145
  ip = getClientIp(request as Request)
146
146
  userAgent = headers.get('user-agent') || null
@@ -194,22 +194,6 @@ export const authConfig: StartAuthJSConfig = {
194
194
 
195
195
  const remember = creds.remember === 'true'
196
196
  const stayLoggedIn = creds.stayLoggedIn === 'true'
197
-
198
- const isNewDevice = (() => {
199
- const cookieHeader = headers?.get?.('cookie') || ''
200
- return !cookieHeader.includes('allblue_device_seen=')
201
- })()
202
- void (async () => {
203
- if (!isNewDevice) return
204
- const { createNotification } = await import('@/server/notifications/notification-service')
205
- await createNotification({
206
- userId: user.id,
207
- category: 'security',
208
- type: 'security.new_device_login',
209
- title: '检测到新设备登录',
210
- body: `登录时间 ${new Date().toLocaleString('zh-CN')}`,
211
- })
212
- })()
213
197
  return {
214
198
  id: user.id,
215
199
  email: user.email,
@@ -10,7 +10,7 @@ export const SDK_GROUPS: SdkGroup[] = [
10
10
  "name": "@tanstack/react-start",
11
11
  "displayName": "TanStack Start",
12
12
  "version": "v1.168.46",
13
- "lastUpdate": "Aug 27, 2026",
13
+ "lastUpdate": "Aug 28, 2026",
14
14
  "source": "apps/web/package.json#dependencies",
15
15
  "docsUrl": "https://tanstack.com/start/latest"
16
16
  },
@@ -18,7 +18,7 @@ export const SDK_GROUPS: SdkGroup[] = [
18
18
  "name": "@tanstack/react-router",
19
19
  "displayName": "TanStack Router",
20
20
  "version": "v1.170.29",
21
- "lastUpdate": "Aug 27, 2026",
21
+ "lastUpdate": "Aug 28, 2026",
22
22
  "source": "apps/web/package.json#dependencies",
23
23
  "docsUrl": "https://tanstack.com/router/latest"
24
24
  },
@@ -26,7 +26,7 @@ export const SDK_GROUPS: SdkGroup[] = [
26
26
  "name": "@tanstack/react-query",
27
27
  "displayName": "TanStack Query",
28
28
  "version": "v5.101.2",
29
- "lastUpdate": "Aug 27, 2026",
29
+ "lastUpdate": "Aug 28, 2026",
30
30
  "source": "apps/web/package.json#dependencies",
31
31
  "docsUrl": "https://tanstack.com/query/latest"
32
32
  },
@@ -34,7 +34,7 @@ export const SDK_GROUPS: SdkGroup[] = [
34
34
  "name": "@tanstack/ai",
35
35
  "displayName": "TanStack AI",
36
36
  "version": "v0.43.0",
37
- "lastUpdate": "Aug 27, 2026",
37
+ "lastUpdate": "Aug 28, 2026",
38
38
  "source": "apps/web/package.json#dependencies",
39
39
  "docsUrl": "https://tanstack.com/ai/latest"
40
40
  },
@@ -42,7 +42,7 @@ export const SDK_GROUPS: SdkGroup[] = [
42
42
  "name": "@tanstack/react-table",
43
43
  "displayName": "TanStack Table",
44
44
  "version": "v9.1.0",
45
- "lastUpdate": "Aug 27, 2026",
45
+ "lastUpdate": "Aug 28, 2026",
46
46
  "source": "apps/web/package.json#dependencies",
47
47
  "docsUrl": "https://tanstack.com/table/latest"
48
48
  },
@@ -50,7 +50,7 @@ export const SDK_GROUPS: SdkGroup[] = [
50
50
  "name": "@tanstack/react-form",
51
51
  "displayName": "TanStack Form",
52
52
  "version": "v2.0.0-alpha.0",
53
- "lastUpdate": "Aug 27, 2026",
53
+ "lastUpdate": "Aug 28, 2026",
54
54
  "source": "apps/web/package.json#dependencies",
55
55
  "docsUrl": "https://tanstack.com/form/latest"
56
56
  },
@@ -58,7 +58,7 @@ export const SDK_GROUPS: SdkGroup[] = [
58
58
  "name": "@tanstack/charts",
59
59
  "displayName": "TanStack Charts",
60
60
  "version": "v0.9.0",
61
- "lastUpdate": "Aug 27, 2026",
61
+ "lastUpdate": "Aug 28, 2026",
62
62
  "source": "apps/admin/package.json#dependencies",
63
63
  "docsUrl": "https://tanstack.com/charts/latest"
64
64
  },
@@ -66,7 +66,7 @@ export const SDK_GROUPS: SdkGroup[] = [
66
66
  "name": "@tanstack/react-pacer",
67
67
  "displayName": "TanStack Pacer",
68
68
  "version": "v0.22.1",
69
- "lastUpdate": "Aug 27, 2026",
69
+ "lastUpdate": "Aug 28, 2026",
70
70
  "source": "apps/web/package.json#dependencies",
71
71
  "docsUrl": "https://tanstack.com/pacer/latest"
72
72
  },
@@ -74,7 +74,7 @@ export const SDK_GROUPS: SdkGroup[] = [
74
74
  "name": "@tanstack/react-hotkeys",
75
75
  "displayName": "TanStack Hotkeys",
76
76
  "version": "v0.10.0",
77
- "lastUpdate": "Aug 27, 2026",
77
+ "lastUpdate": "Aug 28, 2026",
78
78
  "source": "apps/web/package.json#dependencies",
79
79
  "docsUrl": "https://tanstack.com/hotkeys/latest"
80
80
  },
@@ -82,7 +82,7 @@ export const SDK_GROUPS: SdkGroup[] = [
82
82
  "name": "@tanstack/react-virtual",
83
83
  "displayName": "TanStack Virtual",
84
84
  "version": "v3.14.9",
85
- "lastUpdate": "Aug 27, 2026",
85
+ "lastUpdate": "Aug 28, 2026",
86
86
  "source": "apps/admin/package.json#dependencies",
87
87
  "docsUrl": "https://tanstack.com/virtual/latest"
88
88
  }
@@ -95,15 +95,15 @@ export const SDK_GROUPS: SdkGroup[] = [
95
95
  "items": [
96
96
  {
97
97
  "name": "@allbluecn/web-app",
98
- "version": "v0.4.18",
99
- "lastUpdate": "Aug 27, 2026",
98
+ "version": "v0.4.20",
99
+ "lastUpdate": "Aug 28, 2026",
100
100
  "source": "apps/web/package.json",
101
101
  "docsUrl": "https://github.com/allbluecn/allblue"
102
102
  },
103
103
  {
104
104
  "name": "@allbluecn/kernel",
105
105
  "version": "v0.3.0",
106
- "lastUpdate": "Aug 27, 2026",
106
+ "lastUpdate": "Aug 28, 2026",
107
107
  "source": "packages/kernel/package.json",
108
108
  "docsUrl": "https://github.com/allbluecn/allblue"
109
109
  }
@@ -117,42 +117,42 @@ export const SDK_GROUPS: SdkGroup[] = [
117
117
  {
118
118
  "name": "Vite",
119
119
  "version": "v8.1.5",
120
- "lastUpdate": "Aug 27, 2026",
120
+ "lastUpdate": "Aug 28, 2026",
121
121
  "source": "apps/web/package.json#devDependencies",
122
122
  "docsUrl": "https://vite.dev/"
123
123
  },
124
124
  {
125
125
  "name": "Node.js",
126
126
  "version": "v24.19.0",
127
- "lastUpdate": "Aug 27, 2026",
127
+ "lastUpdate": "Aug 28, 2026",
128
128
  "source": ".nvmrc",
129
129
  "docsUrl": "https://nodejs.org/"
130
130
  },
131
131
  {
132
132
  "name": "Prisma",
133
133
  "version": "v7.8.0",
134
- "lastUpdate": "Aug 27, 2026",
134
+ "lastUpdate": "Aug 28, 2026",
135
135
  "source": "packages/database/package.json#devDependencies",
136
136
  "docsUrl": "https://www.prisma.io/docs"
137
137
  },
138
138
  {
139
139
  "name": "shadcn UI",
140
140
  "version": "v1.2.4",
141
- "lastUpdate": "Aug 27, 2026",
141
+ "lastUpdate": "Aug 28, 2026",
142
142
  "source": "apps/web/package.json#dependencies",
143
143
  "docsUrl": "https://ui.shadcn.com/"
144
144
  },
145
145
  {
146
146
  "name": "TipTap",
147
147
  "version": "v3.27.4",
148
- "lastUpdate": "Aug 27, 2026",
148
+ "lastUpdate": "Aug 28, 2026",
149
149
  "source": "packages/shared/package.json#devDependencies",
150
150
  "docsUrl": "https://tiptap.dev/"
151
151
  },
152
152
  {
153
153
  "name": "Lucide React",
154
154
  "version": "v1.24.0",
155
- "lastUpdate": "Aug 27, 2026",
155
+ "lastUpdate": "Aug 28, 2026",
156
156
  "source": "packages/ui/package.json#dependencies",
157
157
  "docsUrl": "https://lucide.dev/"
158
158
  }
@@ -1,10 +1,8 @@
1
1
  // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
2
  import { NavTag } from '@allbluecn/plugins-core/tags/ui'
3
- import { InspirationNavItem } from '@allbluecn/plugins-core/inspiration/ui'
4
- import { PrdNav } from '@allbluecn/plugins-core/prd/ui'
5
3
  import { type ComponentType } from 'react'
6
4
  import type { SettingsTabContribution } from '@allbluecn/kernel'
7
5
 
8
- export const navItems: ComponentType[] = [NavTag, InspirationNavItem, PrdNav]
6
+ export const navItems: ComponentType[] = [NavTag]
9
7
  export const settingsTabs: SettingsTabContribution[] = []
10
8
  export const dashboardWidgets: ComponentType[] = []
@@ -31,7 +31,6 @@ import { Route as NavDashboardRouteRouteImport } from './routes/_nav/dashboard/r
31
31
  import { Route as NavDesignRouteRouteImport } from './routes/_nav/design/route'
32
32
  import { Route as NavInspirationRouteRouteImport } from './routes/_nav/inspiration/route'
33
33
  import { Route as NavKnowledgeRouteRouteImport } from './routes/_nav/knowledge/route'
34
- import { Route as NavNotificationsRouteRouteImport } from './routes/_nav/notifications/route'
35
34
  import { Route as NavNotionLikeRouteRouteImport } from './routes/_nav/notion-like/route'
36
35
  import { Route as NavPrdRouteRouteImport } from './routes/_nav/prd/route'
37
36
  import { Route as NavPricing02RouteRouteImport } from './routes/_nav/pricing-02/route'
@@ -260,13 +259,6 @@ const NavKnowledgeRouteRoute = NavKnowledgeRouteRouteImport.update({
260
259
  path: '/knowledge',
261
260
  getParentRoute: () => NavRouteRoute,
262
261
  } as any)
263
- const NavNotificationsRouteRoute = NavNotificationsRouteRouteImport.update({
264
- id: '/notifications',
265
- path: '/notifications',
266
- getParentRoute: () => NavRouteRoute,
267
- } as any).lazy(() =>
268
- import('./routes/_nav/notifications/route.lazy').then((d) => d.Route),
269
- )
270
262
  const NavNotionLikeRouteRoute = NavNotionLikeRouteRouteImport.update({
271
263
  id: '/notion-like',
272
264
  path: '/notion-like',
@@ -849,7 +841,6 @@ export interface FileRoutesByFullPath {
849
841
  '/design': typeof NavDesignRouteRouteWithChildren
850
842
  '/inspiration': typeof NavInspirationRouteRoute
851
843
  '/knowledge': typeof NavKnowledgeRouteRouteWithChildren
852
- '/notifications': typeof NavNotificationsRouteRoute
853
844
  '/notion-like': typeof NavNotionLikeRouteRoute
854
845
  '/prd': typeof NavPrdRouteRouteWithChildren
855
846
  '/pricing-02': typeof NavPricing02RouteRoute
@@ -951,7 +942,6 @@ export interface FileRoutesByTo {
951
942
  '/chat': typeof NavChatRouteRouteWithChildren
952
943
  '/dashboard': typeof NavDashboardRouteRoute
953
944
  '/inspiration': typeof NavInspirationRouteRoute
954
- '/notifications': typeof NavNotificationsRouteRoute
955
945
  '/notion-like': typeof NavNotionLikeRouteRoute
956
946
  '/pricing-02': typeof NavPricing02RouteRoute
957
947
  '/projects': typeof NavProjectsRouteRouteWithChildren
@@ -1047,7 +1037,6 @@ export interface FileRoutesById {
1047
1037
  '/_nav/design': typeof NavDesignRouteRouteWithChildren
1048
1038
  '/_nav/inspiration': typeof NavInspirationRouteRoute
1049
1039
  '/_nav/knowledge': typeof NavKnowledgeRouteRouteWithChildren
1050
- '/_nav/notifications': typeof NavNotificationsRouteRoute
1051
1040
  '/_nav/notion-like': typeof NavNotionLikeRouteRoute
1052
1041
  '/_nav/prd': typeof NavPrdRouteRouteWithChildren
1053
1042
  '/_nav/pricing-02': typeof NavPricing02RouteRoute
@@ -1153,7 +1142,6 @@ export interface FileRouteTypes {
1153
1142
  | '/design'
1154
1143
  | '/inspiration'
1155
1144
  | '/knowledge'
1156
- | '/notifications'
1157
1145
  | '/notion-like'
1158
1146
  | '/prd'
1159
1147
  | '/pricing-02'
@@ -1255,7 +1243,6 @@ export interface FileRouteTypes {
1255
1243
  | '/chat'
1256
1244
  | '/dashboard'
1257
1245
  | '/inspiration'
1258
- | '/notifications'
1259
1246
  | '/notion-like'
1260
1247
  | '/pricing-02'
1261
1248
  | '/projects'
@@ -1350,7 +1337,6 @@ export interface FileRouteTypes {
1350
1337
  | '/_nav/design'
1351
1338
  | '/_nav/inspiration'
1352
1339
  | '/_nav/knowledge'
1353
- | '/_nav/notifications'
1354
1340
  | '/_nav/notion-like'
1355
1341
  | '/_nav/prd'
1356
1342
  | '/_nav/pricing-02'
@@ -1604,13 +1590,6 @@ declare module '@tanstack/react-router' {
1604
1590
  preLoaderRoute: typeof NavKnowledgeRouteRouteImport
1605
1591
  parentRoute: typeof NavRouteRoute
1606
1592
  }
1607
- '/_nav/notifications': {
1608
- id: '/_nav/notifications'
1609
- path: '/notifications'
1610
- fullPath: '/notifications'
1611
- preLoaderRoute: typeof NavNotificationsRouteRouteImport
1612
- parentRoute: typeof NavRouteRoute
1613
- }
1614
1593
  '/_nav/notion-like': {
1615
1594
  id: '/_nav/notion-like'
1616
1595
  path: '/notion-like'
@@ -2564,7 +2543,6 @@ interface NavRouteRouteChildren {
2564
2543
  NavDesignRouteRoute: typeof NavDesignRouteRouteWithChildren
2565
2544
  NavInspirationRouteRoute: typeof NavInspirationRouteRoute
2566
2545
  NavKnowledgeRouteRoute: typeof NavKnowledgeRouteRouteWithChildren
2567
- NavNotificationsRouteRoute: typeof NavNotificationsRouteRoute
2568
2546
  NavNotionLikeRouteRoute: typeof NavNotionLikeRouteRoute
2569
2547
  NavPrdRouteRoute: typeof NavPrdRouteRouteWithChildren
2570
2548
  NavPricing02RouteRoute: typeof NavPricing02RouteRoute
@@ -2587,7 +2565,6 @@ const NavRouteRouteChildren: NavRouteRouteChildren = {
2587
2565
  NavDesignRouteRoute: NavDesignRouteRouteWithChildren,
2588
2566
  NavInspirationRouteRoute: NavInspirationRouteRoute,
2589
2567
  NavKnowledgeRouteRoute: NavKnowledgeRouteRouteWithChildren,
2590
- NavNotificationsRouteRoute: NavNotificationsRouteRoute,
2591
2568
  NavNotionLikeRouteRoute: NavNotionLikeRouteRoute,
2592
2569
  NavPrdRouteRoute: NavPrdRouteRouteWithChildren,
2593
2570
  NavPricing02RouteRoute: NavPricing02RouteRoute,
@@ -37,6 +37,7 @@ const groups: SettingsGroup[] = [
37
37
  titleKey: 'tabs.general',
38
38
  tabs: [
39
39
  { labelKey: 'tabs.generalSettings', path: '/settings/general', icon: Settings },
40
+ { labelKey: 'tabs.notifications', path: '/settings/notifications', icon: Bell },
40
41
  ],
41
42
  },
42
43
  {
@@ -45,7 +46,6 @@ const groups: SettingsGroup[] = [
45
46
  { labelKey: 'tabs.accountProfile', path: '/settings/account', icon: User },
46
47
  { labelKey: 'tabs.teamMembers', path: '/settings/team', icon: Users },
47
48
  { labelKey: 'tabs.dataPrivacy', path: '/settings/privacy', icon: ShieldCheck },
48
- { labelKey: 'tabs.notifications', path: '/settings/notifications', icon: Bell },
49
49
  ],
50
50
  },
51
51
  {
@@ -32,17 +32,6 @@ const requireBilling = () => {
32
32
  return billing
33
33
  }
34
34
 
35
- async function emitBillingNotification(userId: string, type: string, title: string, body: string, link?: string) {
36
- void (async () => {
37
- try {
38
- const { createNotification } = await import('@/server/notifications/notification-service')
39
- await createNotification({ userId, category: 'billing', type, title, body, link })
40
- } catch (e) {
41
- console.error('[billing] 通知发送失败:', e instanceof Error ? e.message : 'unknown')
42
- }
43
- })()
44
- }
45
-
46
35
  export const getEffectivePlanFn = createServerFn({ method: 'GET' }).handler(async () => {
47
36
  const request = getRequest()
48
37
  const session = await requireAuth(request)
@@ -103,12 +92,10 @@ export const createCustomerPortalSessionFn = createServerFn({ method: 'POST' }).
103
92
  const userId = session.user!.id!
104
93
 
105
94
  const baseUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
106
- const result = await requireBilling().createCustomerPortalSession({
95
+ return requireBilling().createCustomerPortalSession({
107
96
  userId,
108
97
  returnUrl: `${baseUrl}/settings/billing`,
109
98
  })
110
- await emitBillingNotification(userId, 'billing.portal_access', '客户门户已打开', '您已打开管理订阅的客户门户', '/settings/billing')
111
- return result
112
99
  },
113
100
  )
114
101
 
@@ -172,17 +159,9 @@ export const updateSeatsFn = createServerFn({ method: 'POST' })
172
159
  )
173
160
  }
174
161
 
175
- const result = await requireBilling().updateSeats({
162
+ return requireBilling().updateSeats({
176
163
  userId,
177
164
  addonSeats: data.addonSeats,
178
165
  successUrl: data.successUrl ?? `${baseUrl}/settings/billing?checkout=success`,
179
166
  })
180
- await emitBillingNotification(
181
- userId,
182
- 'billing.seats_changed',
183
- '席位变更',
184
- `席位已调整为 ${subscription.plan.maxSeats + data.addonSeats}`,
185
- '/settings/billing',
186
- )
187
- return result
188
167
  })
@@ -22,7 +22,6 @@ import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
23
  import type { CommentWithReplies, ReactionGroup } from "@/components/story/types";
24
24
  import { USER_BRIEF_SELECT, castUserBrief, aggregateReactions } from "./types";
25
- import { createNotification } from '@/server/notifications/notification-service'
26
25
 
27
26
  export const listCommentsFn = createServerFn({ method: "GET" })
28
27
  .validator(z.object({ storyId: z.string() }))
@@ -102,47 +101,6 @@ export const createCommentFn = createServerFn({ method: "POST" })
102
101
  },
103
102
  });
104
103
 
105
- const commenter = await prisma.user.findUnique({
106
- where: { id: userId },
107
- select: { username: true },
108
- })
109
- const story = await prisma.story.findUnique({
110
- where: { id: data.storyId },
111
- select: { createdById: true },
112
- })
113
- if (story?.createdById && story.createdById !== userId) {
114
- void createNotification({
115
- userId: story.createdById,
116
- category: 'collaboration',
117
- type: 'collaboration.comment',
118
- title: `${commenter?.username ?? '有人'} 评论了你的 Story`,
119
- link: `/stories/${data.storyId}`,
120
- })
121
- }
122
-
123
- const mentionMatches = data.content.match(/@([a-zA-Z0-9_\u4e00-\u9fa5]+)/g) ?? []
124
- if (mentionMatches.length > 0) {
125
- const mentionedNames = [...new Set(mentionMatches.map((m) => m.slice(1)))].filter(
126
- (name) => name.toLowerCase() !== commenter?.username?.toLowerCase(),
127
- )
128
- if (mentionedNames.length > 0) {
129
- const mentionedUsers = await prisma.user.findMany({
130
- where: { username: { in: mentionedNames } },
131
- select: { id: true },
132
- })
133
- for (const u of mentionedUsers) {
134
- if (u.id === userId) continue
135
- void createNotification({
136
- userId: u.id,
137
- category: 'collaboration',
138
- type: 'collaboration.mention',
139
- title: `${commenter?.username ?? '有人'} 在评论中提到了你`,
140
- link: `/stories/${data.storyId}`,
141
- })
142
- }
143
- }
144
- }
145
-
146
104
  await prisma.storyActivity.create({
147
105
  data: {
148
106
  storyId: data.storyId,
@@ -8,7 +8,6 @@ import { requireTeamSeats } from '@/server/resources'
8
8
  import { auditLog } from '@/server/audit'
9
9
  import { getEmailSender } from '@/server/email/sender'
10
10
  import { USER_BRIEF_SELECT } from '@/server/team-access'
11
- import { createNotification } from '@/server/notifications/notification-service'
12
11
 
13
12
  const INVITE_EXPIRES_DAYS = 7
14
13
  const OWNER_SELECT = { id: true, username: true, email: true, avatarConfig: true, bgShape: true, bgColor: true } as const
@@ -365,14 +364,6 @@ export const removeTeamMemberFn = createServerFn({ method: 'POST' })
365
364
  await tx.teamMembership.delete({ where: { id: m.id } })
366
365
  await tx.projectMember.deleteMany({ where: { userId: m.memberId } })
367
366
  })
368
-
369
- void createNotification({
370
- userId: m.memberId,
371
- category: 'team',
372
- type: 'team.member_removed',
373
- title: '你已被移出团队',
374
- })
375
-
376
367
  await auditLog({
377
368
  workspaceId: '',
378
369
  userId: ownerId,
@@ -464,14 +455,6 @@ export const approveTeamJoinRequestFn = createServerFn({ method: 'POST' })
464
455
  throw err
465
456
  }
466
457
 
467
- void createNotification({
468
- userId: applicantUserId,
469
- category: 'team',
470
- type: 'team.invite_approved',
471
- title: '你的加入申请已被批准',
472
- link: '/settings/team',
473
- })
474
-
475
458
  await auditLog({
476
459
  workspaceId: '',
477
460
  userId: ownerId,
@@ -499,19 +482,6 @@ export const rejectTeamJoinRequestFn = createServerFn({ method: 'POST' })
499
482
  data: { status: 'rejected', processedAt: new Date() },
500
483
  })
501
484
 
502
- const applicant = await prisma.user.findUnique({
503
- where: { email: reqRow.email?.toLowerCase() ?? '' },
504
- select: { id: true },
505
- })
506
- if (applicant?.id) {
507
- void createNotification({
508
- userId: applicant.id,
509
- category: 'team',
510
- type: 'team.invite_rejected',
511
- title: '你的加入申请未被通过',
512
- })
513
- }
514
-
515
485
  await auditLog({
516
486
  workspaceId: '',
517
487
  userId: ownerId,
@@ -665,13 +635,6 @@ export const applyTeamInvitationFn = createServerFn({ method: 'POST' })
665
635
  data: { status: 'pending_approval', applicantEmail: email },
666
636
  })
667
637
  }
668
- void createNotification({
669
- userId: inv.ownerId,
670
- category: 'team',
671
- type: 'team.invite_request',
672
- title: `${email} 申请加入你的团队`,
673
- link: '/settings/team',
674
- })
675
638
  return { success: true, requiresApproval: true, duplicate: !!existing }
676
639
  }
677
640
 
@@ -701,14 +664,6 @@ export const applyTeamInvitationFn = createServerFn({ method: 'POST' })
701
664
  update: { status: 'pending', userId: existingUser?.id ?? null, processedAt: null },
702
665
  })
703
666
 
704
- void createNotification({
705
- userId: inv.ownerId,
706
- category: 'team',
707
- type: 'team.invite_request',
708
- title: `${email} 申请加入你的团队`,
709
- link: '/settings/team',
710
- })
711
-
712
667
  await auditLog({
713
668
  workspaceId: '',
714
669
  userId: '',
@@ -717,6 +672,7 @@ export const applyTeamInvitationFn = createServerFn({ method: 'POST' })
717
672
  action: 'team.join.apply',
718
673
  metadata: { applicantEmail: email },
719
674
  })
675
+
720
676
  return { success: true, requiresApproval: true, duplicate: false }
721
677
  })
722
678
 
@@ -830,14 +786,6 @@ export const approveTeamInvitationFn = createServerFn({ method: 'POST' })
830
786
  throw err
831
787
  }
832
788
 
833
- void createNotification({
834
- userId: applicantUserId,
835
- category: 'team',
836
- type: 'team.invite_approved',
837
- title: '你的加入申请已被批准',
838
- link: '/settings/team',
839
- })
840
-
841
789
  await auditLog({
842
790
  workspaceId: '',
843
791
  userId: ownerId,
package/src/start.ts CHANGED
@@ -44,28 +44,6 @@ const aiPortsMiddleware = createMiddleware().server(async ({ next }) => {
44
44
  return next()
45
45
  })
46
46
 
47
- const deviceCookieMiddleware = createMiddleware().server(async ({ request, next }: any) => {
48
- if (!request.url.includes('/api/auth/callback/credentials')) {
49
- return next()
50
- }
51
- const result = await next() as any
52
- const resHeaders = result?.headers || result?.response?.headers || {}
53
- const existingSetCookie = typeof resHeaders.getSetCookie === 'function'
54
- ? resHeaders.getSetCookie()
55
- : (resHeaders.get?.('set-cookie') || '').split(',').filter(Boolean)
56
- const isLoginSuccess = existingSetCookie.some((c: string) => c.includes('auth.session-token'))
57
- if (!isLoginSuccess) return result
58
- const requestCookies = request.headers.get('cookie') || ''
59
- if (requestCookies.includes('allblue_device_seen=')) return result
60
- const newHeaders = new Headers(resHeaders)
61
- existingSetCookie.forEach((c: string) => newHeaders.append('set-cookie', c))
62
- newHeaders.append('set-cookie', 'allblue_device_seen=1; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax')
63
- if (result instanceof Response) {
64
- return new Response(result.body, { status: result.status, statusText: result.statusText, headers: newHeaders })
65
- }
66
- return { ...result, headers: newHeaders }
67
- })
68
-
69
47
  const apiCorsMiddleware = createMiddleware().server(async ({ request, next }: any) => {
70
48
  const needsCors = request.url.includes('/api/') || request.url.includes('/_serverFn/')
71
49
  if (!needsCors) {
@@ -119,6 +97,6 @@ export const startInstance = createStart(async () => {
119
97
  } as any)
120
98
  }
121
99
  return {
122
- requestMiddleware: [requestContextMiddleware, neonKeepaliveMiddleware, aiPortsMiddleware, apiCorsMiddleware, deviceCookieMiddleware, csrfMiddleware, auditRequestMiddleware],
100
+ requestMiddleware: [requestContextMiddleware, neonKeepaliveMiddleware, aiPortsMiddleware, apiCorsMiddleware, csrfMiddleware, auditRequestMiddleware],
123
101
  }
124
102
  })
@@ -1,41 +0,0 @@
1
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { describe, it, expect } from 'vitest'
3
- import { parseNotificationEvent } from '../use-notifications'
4
-
5
- describe('parseNotificationEvent', () => {
6
- it('解析合法 notification 事件', () => {
7
- const raw = JSON.stringify({
8
- type: 'CUSTOM',
9
- name: 'notifications',
10
- value: {
11
- kind: 'notification',
12
- notification: {
13
- id: 'n1',
14
- category: 'team',
15
- type: 'team.invite_request',
16
- title: 't',
17
- body: null,
18
- link: null,
19
- readAt: null,
20
- createdAt: '2026-08-26T00:00:00Z',
21
- },
22
- },
23
- })
24
- const parsed = parseNotificationEvent(raw)
25
- expect(parsed?.kind).toBe('notification')
26
- })
27
-
28
- it('解析 unread_snapshot 事件', () => {
29
- const raw = JSON.stringify({ type: 'CUSTOM', name: 'notifications', value: { kind: 'unread_snapshot', count: 5 } })
30
- expect(parseNotificationEvent(raw)).toEqual({ kind: 'unread_snapshot', count: 5 })
31
- })
32
-
33
- it('非法 JSON 返回 null', () => {
34
- expect(parseNotificationEvent('not json')).toBeNull()
35
- })
36
-
37
- it('非 notifications 通道的 CUSTOM 事件返回 null', () => {
38
- const raw = JSON.stringify({ type: 'CUSTOM', name: 'other', value: { kind: 'unread_snapshot', count: 5 } })
39
- expect(parseNotificationEvent(raw)).toBeNull()
40
- })
41
- })
@@ -1,23 +0,0 @@
1
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { createLazyFileRoute } from '@tanstack/react-router'
3
- import { useTranslation } from 'react-i18next'
4
- import { NotificationList } from '@/components/notifications/notification-list'
5
-
6
- export const Route = createLazyFileRoute('/_nav/notifications')({
7
- component: NotificationsPage,
8
- })
9
-
10
- function NotificationsPage() {
11
- const { t } = useTranslation('notifications')
12
- return (
13
- <div className="h-full overflow-y-auto">
14
- <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
15
- <div className="flex flex-col gap-1">
16
- <h1 className="text-2xl font-semibold">{t('title')}</h1>
17
- <p className="text-sm text-muted-foreground">{t('description')}</p>
18
- </div>
19
- <NotificationList />
20
- </div>
21
- </div>
22
- )
23
- }
@@ -1,8 +0,0 @@
1
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { createFileRoute } from '@tanstack/react-router'
3
-
4
- import { listNotificationsFn } from '@/server/serverFns/notifications'
5
-
6
- export const Route = createFileRoute('/_nav/notifications')({
7
- loader: () => listNotificationsFn({ data: {} }),
8
- })
@@ -1,114 +0,0 @@
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
- })
@@ -1,30 +0,0 @@
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
- })
@@ -1,63 +0,0 @@
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
- })