@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 +7 -7
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -2
- package/src/components/layout/sidebar-03/nav-notifications.tsx +35 -115
- package/src/config/sidebar-routes.tsx +16 -0
- package/src/lib/auth/auth.config.ts +1 -17
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/plugins/ui.gen.ts +1 -3
- package/src/routeTree.gen.ts +0 -23
- package/src/routes/_nav/settings/-settings-tabs.tsx +1 -1
- package/src/server/serverFns/billing.ts +2 -23
- package/src/server/serverFns/story/story-comments.ts +0 -42
- package/src/server/serverFns/team.ts +1 -53
- package/src/start.ts +1 -23
- package/src/hooks/__tests__/use-notifications.test.ts +0 -41
- package/src/routes/_nav/notifications/route.lazy.tsx +0 -23
- package/src/routes/_nav/notifications/route.tsx +0 -8
- package/src/server/notifications/__tests__/notification-service.test.ts +0 -114
- package/src/server/notifications/__tests__/stream.test.ts +0 -30
- package/src/server/serverFns/__tests__/notifications.test.ts +0 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
140
|
-
"@allbluecn/
|
|
141
|
-
"@allbluecn/
|
|
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/
|
|
145
|
-
"@allbluecn/
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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="
|
|
84
|
-
aria-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
|
-
<
|
|
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
|
-
{
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
99
|
-
"lastUpdate": "Aug
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
155
|
+
"lastUpdate": "Aug 28, 2026",
|
|
156
156
|
"source": "packages/ui/package.json#dependencies",
|
|
157
157
|
"docsUrl": "https://lucide.dev/"
|
|
158
158
|
}
|
package/src/plugins/ui.gen.ts
CHANGED
|
@@ -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
|
|
6
|
+
export const navItems: ComponentType[] = [NavTag]
|
|
9
7
|
export const settingsTabs: SettingsTabContribution[] = []
|
|
10
8
|
export const dashboardWidgets: ComponentType[] = []
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
})
|