@allbluecn/web-app 0.4.22 → 0.5.1

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 (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -19,19 +19,21 @@ import { createLazyFileRoute, Link } from "@tanstack/react-router";
19
19
  import { useRouter } from "@tanstack/react-router";
20
20
  import { useTranslation } from "react-i18next";
21
21
  import { useEffect, useState } from "react";
22
- import { ArrowLeft, Loader2 } from "lucide-react";
22
+ import { ArrowLeft, BookOpenText, Loader2 } from "lucide-react";
23
23
  import { toast } from '@/components/ui/sonner';
24
24
  import { Button } from "@/components/ui/button";
25
25
  import { Input } from "@/components/ui/input";
26
26
  import { Textarea } from "@/components/ui/textarea";
27
27
  import { Label } from "@/components/ui/label";
28
28
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
29
+ import { Checkbox } from "@/components/ui/checkbox";
30
+ import { Badge } from "@/components/ui/badge";
29
31
  import { IconPicker } from "@/components/shared/icon-picker";
30
32
  import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
31
33
  import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
32
34
  import { ProjectMemberManager } from "@/components/project/project-member-manager";
33
35
  import { extractDisplayId, validateDisplayId } from "@/lib/project-display-id";
34
- import { checkDisplayIdAvailableFn, updateProjectFn, deleteProjectFn } from "@/server/serverFns/project";
36
+ import { checkDisplayIdAvailableFn, updateProjectFn, deleteProjectFn, setProjectKbsFn } from "@/server/serverFns/project";
35
37
  import { useTimezone } from "@/providers/timezone-provider";
36
38
  import { TimezoneSelect } from "@/components/shared/timezone-select";
37
39
  import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
@@ -39,7 +41,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
39
41
  function ProjectSettingsPage() {
40
42
  const { t } = useTranslation('projects');
41
43
  const router = useRouter();
42
- const { project, members } = Route.useLoaderData();
44
+ const { project, members, linkedKbIds, candidateKbs } = Route.useLoaderData();
43
45
  const session = Route.useRouteContext({ select: (c) => c.session });
44
46
  const currentUserId = session?.user?.id;
45
47
  const currentUserRole =
@@ -57,6 +59,8 @@ function ProjectSettingsPage() {
57
59
  const [projectTimezone, setProjectTimezone] = useState(project.timezone ?? "");
58
60
  const [saving, setSaving] = useState(false);
59
61
  const [deleteOpen, setDeleteOpen] = useState(false);
62
+ const [kbChecked, setKbChecked] = useState<Set<string>>(() => new Set(linkedKbIds));
63
+ const [savingKbs, setSavingKbs] = useState(false);
60
64
  const [deleteMode, setDeleteMode] = useState<"transfer" | "clear">("transfer");
61
65
  const userTimezone = useTimezone();
62
66
 
@@ -102,6 +106,18 @@ function ProjectSettingsPage() {
102
106
  }
103
107
  };
104
108
 
109
+ const saveKbs = async () => {
110
+ setSavingKbs(true);
111
+ try {
112
+ await setProjectKbsFn({ data: { projectId: project.id, kbIds: [...kbChecked] } });
113
+ toast.success(t('settings.kbSection.saved'));
114
+ } catch (err) {
115
+ toast.error((err as Error).message || t('settings.kbSection.saveFailed'));
116
+ } finally {
117
+ setSavingKbs(false);
118
+ }
119
+ };
120
+
105
121
  const handleDelete = async (mode: "transfer" | "clear") => {
106
122
  try {
107
123
  await deleteProjectFn({ data: { projectId: project.id, mode } })
@@ -225,6 +241,61 @@ function ProjectSettingsPage() {
225
241
  </Card>
226
242
  )}
227
243
 
244
+ <Card className="mb-6">
245
+ <CardHeader>
246
+ <CardTitle>{t('settings.kbSection.title')}</CardTitle>
247
+ </CardHeader>
248
+ <CardContent>
249
+ <p className="mb-3 text-sm text-muted-foreground">
250
+ {t('settings.kbSection.desc')}
251
+ </p>
252
+ {candidateKbs.filter((kb) => kb.visibility !== 'private').length === 0 && (
253
+ <p className="text-sm text-muted-foreground">{t('settings.kbSection.empty')}</p>
254
+ )}
255
+ <div className="flex flex-col gap-2">
256
+ {candidateKbs
257
+ .filter((kb) => kb.visibility !== 'private')
258
+ .map((kb) => {
259
+ const isGeneral = kb.visibility === 'general';
260
+ return (
261
+ <label
262
+ key={kb.id}
263
+ className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
264
+ isGeneral ? 'opacity-60' : 'cursor-pointer'
265
+ }`}
266
+ >
267
+ <Checkbox
268
+ checked={isGeneral ? false : kbChecked.has(kb.id)}
269
+ disabled={isGeneral}
270
+ onCheckedChange={(v) =>
271
+ setKbChecked((prev) => {
272
+ const next = new Set(prev);
273
+ if (v) next.add(kb.id);
274
+ else next.delete(kb.id);
275
+ return next;
276
+ })
277
+ }
278
+ />
279
+ <BookOpenText className="size-4 text-muted-foreground" />
280
+ <span className="flex-1">{kb.name}</span>
281
+ {isGeneral ? (
282
+ <span className="text-xs text-muted-foreground">
283
+ {t('settings.kbSection.generalDisabled')}
284
+ </span>
285
+ ) : (
286
+ <Badge variant="outline" className="text-xs">{t('knowledge:toolbar.team')}</Badge>
287
+ )}
288
+ </label>
289
+ );
290
+ })}
291
+ </div>
292
+ <Button onClick={saveKbs} disabled={savingKbs} className="mt-3">
293
+ {savingKbs ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
294
+ {t('settings.kbSection.save')}
295
+ </Button>
296
+ </CardContent>
297
+ </Card>
298
+
228
299
  <Card className="mb-6">
229
300
  <CardHeader>
230
301
  <CardTitle>{t('settings.members')}</CardTitle>
@@ -19,14 +19,18 @@ import { createFileRoute } from "@tanstack/react-router";
19
19
  import {
20
20
  getProjectDetailFn,
21
21
  listProjectMembersFn,
22
+ listProjectKbsFn,
22
23
  } from "@/server/serverFns/project";
24
+ import { listKnowledgeFn } from "@/server/serverFns/knowledge";
23
25
 
24
26
  export const Route = createFileRoute("/_nav/projects/$projectId/settings")({
25
27
  loader: async ({ params }) => {
26
- const [project, members] = await Promise.all([
28
+ const [project, members, linkedKbs, myKbs] = await Promise.all([
27
29
  getProjectDetailFn({ data: { projectId: params.projectId } }),
28
30
  listProjectMembersFn({ data: { projectId: params.projectId } }),
31
+ listProjectKbsFn({ data: { projectId: params.projectId } }),
32
+ listKnowledgeFn({ data: {} }),
29
33
  ]);
30
- return { project, members };
34
+ return { project, members, linkedKbIds: linkedKbs.map((k) => k.id), candidateKbs: myKbs };
31
35
  },
32
36
  });
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { createLazyFileRoute } from '@tanstack/react-router'
19
19
  import { useTranslation } from 'react-i18next'
20
- import { NotificationPreferences } from '@/components/notifications/notification-preferences'
20
+ import { SettingsNotifications } from '@/components/notifications/settings-notifications'
21
21
 
22
22
  export const Route = createLazyFileRoute('/_nav/settings/notifications')({
23
23
  component: NotificationsSettingsPage,
@@ -25,13 +25,14 @@ export const Route = createLazyFileRoute('/_nav/settings/notifications')({
25
25
 
26
26
  function NotificationsSettingsPage() {
27
27
  const { t } = useTranslation('notifications')
28
+
28
29
  return (
29
30
  <div className="flex flex-col gap-6 p-6">
30
- <div className="flex flex-col gap-1">
31
- <h1 className="text-xl font-semibold">{t('preferences.title')}</h1>
32
- <p className="text-sm text-muted-foreground">{t('preferences.description')}</p>
31
+ <div>
32
+ <h2 className="text-lg font-semibold">{t('settings.title')}</h2>
33
+ <p className="text-sm text-muted-foreground">{t('settings.description')}</p>
33
34
  </div>
34
- <NotificationPreferences />
35
+ <SettingsNotifications />
35
36
  </div>
36
37
  )
37
38
  }
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createFileRoute } from "@tanstack/react-router";
19
+ import { prisma } from "@allbluecn/database";
20
+ import { requireAuth } from "@/server/auth-helpers";
21
+
22
+ // URL 路径中的文件名不可信,Content-Disposition 用 RFC 5987 filename* 编码
23
+ function contentDisposition(fileName: string): string {
24
+ const encoded = encodeURIComponent(fileName);
25
+ return `inline; filename*=UTF-8''${encoded}`;
26
+ }
27
+
28
+ export const Route = createFileRoute("/api/attachments/$id/content")({
29
+ server: {
30
+ handlers: {
31
+ GET: async ({ request, params }) => {
32
+ try {
33
+ await requireAuth(request);
34
+
35
+ const attachment = await prisma.storyAttachment.findUnique({
36
+ where: { id: params.id },
37
+ select: { fileName: true, mimeType: true, bytes: true },
38
+ });
39
+ if (!attachment || !attachment.bytes) {
40
+ return new Response("Not found", { status: 404 });
41
+ }
42
+
43
+ const body = new Uint8Array(attachment.bytes);
44
+ return new Response(body, {
45
+ headers: {
46
+ "Content-Type": attachment.mimeType || "application/octet-stream",
47
+ "Content-Length": String(body.byteLength),
48
+ "Content-Disposition": contentDisposition(attachment.fileName),
49
+ "Cache-Control": "private, max-age=31536000, immutable",
50
+ },
51
+ });
52
+ } catch (e) {
53
+ const message = e instanceof Error ? e.message : "读取失败";
54
+ const status = message === "UNAUTHORIZED" ? 401 : 500;
55
+ return new Response(message, { status });
56
+ }
57
+ },
58
+ },
59
+ },
60
+ });
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createFileRoute } from "@tanstack/react-router";
19
+ import { prisma } from "@allbluecn/database";
20
+ import { requireAuth, requireUserInDb } from "@/server/auth-helpers";
21
+ import { ACTIVITY_SELECT, toActivityItem } from "@/server/serverFns/story/types";
22
+ import { inferMimeType } from "@/components/story/relations/format";
23
+
24
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
25
+
26
+ export const Route = createFileRoute("/api/attachments/upload")({
27
+ server: {
28
+ handlers: {
29
+ POST: async ({ request }) => {
30
+ try {
31
+ const session = await requireAuth(request);
32
+ const userId = session.user!.id!;
33
+ await requireUserInDb(userId);
34
+
35
+ const formData = await request.formData();
36
+ const storyId = formData.get("storyId");
37
+ const file = formData.get("file");
38
+ const rawDescription = formData.get("description");
39
+ const description =
40
+ typeof rawDescription === "string" && rawDescription.trim()
41
+ ? rawDescription.trim().slice(0, 500)
42
+ : null;
43
+ const attachmentId = formData.get("attachmentId") as string | null;
44
+ const fileHash = formData.get("fileHash") as string | null;
45
+ if (typeof storyId !== "string" || !storyId) {
46
+ return Response.json({ error: "缺少 storyId" }, { status: 400 });
47
+ }
48
+ if (!(file instanceof File)) {
49
+ return Response.json({ error: "缺少文件" }, { status: 400 });
50
+ }
51
+ if (file.size <= 0 || file.size > MAX_FILE_SIZE) {
52
+ return Response.json({ error: "文件大小需在 10MB 以内" }, { status: 400 });
53
+ }
54
+
55
+ const story = await prisma.story.findUnique({
56
+ where: { id: storyId },
57
+ select: { id: true },
58
+ });
59
+ if (!story) {
60
+ return Response.json({ error: "需求不存在" }, { status: 404 });
61
+ }
62
+
63
+ const bytes = new Uint8Array(await file.arrayBuffer());
64
+ let attachment;
65
+ let activity;
66
+
67
+ if (attachmentId) {
68
+ // 编辑模式:替换现有附件
69
+ attachment = await prisma.storyAttachment.update({
70
+ where: { id: attachmentId },
71
+ data: {
72
+ fileName: file.name.slice(0, 255),
73
+ fileSize: BigInt(file.size),
74
+ mimeType: inferMimeType(file.name, file.type),
75
+ description,
76
+ bytes,
77
+ fileHash: fileHash ?? null,
78
+ },
79
+ });
80
+ activity = await prisma.storyActivity.create({
81
+ data: {
82
+ storyId,
83
+ userId,
84
+ action: "attachment_updated",
85
+ newValue: file.name.slice(0, 255),
86
+ },
87
+ select: ACTIVITY_SELECT,
88
+ });
89
+ } else {
90
+ // 新增模式:先检查同 story 是否已有相同 hash 的附件
91
+ const existing = fileHash
92
+ ? await prisma.storyAttachment.findFirst({
93
+ where: { storyId, fileHash },
94
+ select: { id: true },
95
+ })
96
+ : null;
97
+ if (existing) {
98
+ return Response.json({ duplicated: true, existingId: existing.id }, { status: 200 });
99
+ }
100
+ attachment = await prisma.storyAttachment.create({
101
+ data: {
102
+ storyId,
103
+ fileName: file.name.slice(0, 255),
104
+ fileSize: BigInt(file.size),
105
+ mimeType: inferMimeType(file.name, file.type),
106
+ description,
107
+ storageUrl: "pending",
108
+ bytes,
109
+ fileHash: fileHash ?? null,
110
+ },
111
+ });
112
+ const storageUrl = `/api/attachments/${attachment.id}/content`;
113
+ await prisma.storyAttachment.update({
114
+ where: { id: attachment.id },
115
+ data: { storageUrl },
116
+ });
117
+ activity = await prisma.storyActivity.create({
118
+ data: {
119
+ storyId,
120
+ userId,
121
+ action: "attachment_added",
122
+ newValue: file.name.slice(0, 255),
123
+ },
124
+ select: ACTIVITY_SELECT,
125
+ });
126
+ }
127
+
128
+ const storageUrl = `/api/attachments/${attachment.id}/content`;
129
+
130
+ return Response.json({
131
+ id: attachment.id,
132
+ fileName: attachment.fileName,
133
+ fileSize: Number(attachment.fileSize),
134
+ mimeType: attachment.mimeType,
135
+ description: attachment.description,
136
+ storageUrl,
137
+ fileHash: attachment.fileHash,
138
+ activity: toActivityItem(activity),
139
+ });
140
+ } catch (e) {
141
+ const message = e instanceof Error ? e.message : "上传失败";
142
+ const status = message === "UNAUTHORIZED" ? 401 : 500;
143
+ return Response.json({ error: message }, { status });
144
+ }
145
+ },
146
+ },
147
+ },
148
+ });
@@ -21,6 +21,7 @@ import type { StreamChunk } from '@tanstack/ai'
21
21
  import { prisma } from '@allbluecn/database'
22
22
  import { getCurrentSession } from '@/lib/session'
23
23
  import { notificationBus } from '@/server/notifications/event-bus'
24
+ import { deliverDueDigests } from '@/server/notifications/notification-service'
24
25
  import type { NotificationSseValue } from '@allbluecn/shared'
25
26
 
26
27
  export const Route = createFileRoute('/api/notifications/stream')({
@@ -60,6 +61,9 @@ export const Route = createFileRoute('/api/notifications/stream')({
60
61
  value: { kind: 'unread_snapshot', count },
61
62
  } as unknown as StreamChunk
62
63
 
64
+ // 连接建立时检查到期 digest 摘要(fire-and-forget,摘要经 bus 实时推送)
65
+ void deliverDueDigests(userId)
66
+
63
67
  while (!abortController.signal.aborted) {
64
68
  if (queue.length === 0) {
65
69
  await new Promise<void>((resolve) => {
@@ -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
+ })