@allbluecn/web-app 0.10.3 → 0.11.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.
- package/package.json +24 -11
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/fetch-json.ts +23 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +10 -2
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
- package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +26 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +36 -13
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +122 -0
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +50 -12
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +114 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +105 -4
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
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 { describe, it, expect } from "vitest";
|
|
19
|
+
import { quickDocItemSchema, quickDocItemKey, generalDocHref } from "../workspace-kb-utils";
|
|
20
|
+
|
|
21
|
+
describe("quickDocItemSchema 互斥校验", () => {
|
|
22
|
+
it("docId 单独存在合法", () => {
|
|
23
|
+
expect(quickDocItemSchema.parse({ docId: "d1", displayTitle: "标题" })).toMatchObject({ docId: "d1" });
|
|
24
|
+
});
|
|
25
|
+
it("externalGroup+externalId 合法", () => {
|
|
26
|
+
expect(
|
|
27
|
+
quickDocItemSchema.parse({ externalGroup: "legal", externalId: "x1", displayTitle: "标题" }),
|
|
28
|
+
).toMatchObject({ externalGroup: "legal" });
|
|
29
|
+
});
|
|
30
|
+
it("docId 与 external 同时存在拒绝", () => {
|
|
31
|
+
expect(() =>
|
|
32
|
+
quickDocItemSchema.parse({ docId: "d1", externalGroup: "legal", externalId: "x1", displayTitle: "标题" }),
|
|
33
|
+
).toThrow();
|
|
34
|
+
});
|
|
35
|
+
it("两者皆无拒绝", () => {
|
|
36
|
+
expect(() => quickDocItemSchema.parse({ displayTitle: "标题" })).toThrow();
|
|
37
|
+
});
|
|
38
|
+
it("空标题拒绝", () => {
|
|
39
|
+
expect(() => quickDocItemSchema.parse({ docId: "d1", displayTitle: "" })).toThrow();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("quickDocItemKey / generalDocHref", () => {
|
|
44
|
+
it("doc 态返回 docId", () => {
|
|
45
|
+
expect(quickDocItemKey({ docId: "d1", externalGroup: undefined, externalId: undefined })).toBe("d1");
|
|
46
|
+
});
|
|
47
|
+
it("ext 态返回 ext: 前缀 id", () => {
|
|
48
|
+
expect(quickDocItemKey({ docId: undefined, externalGroup: "legal", externalId: "x1" })).toBe("ext:legal:x1");
|
|
49
|
+
});
|
|
50
|
+
it("href 映射", () => {
|
|
51
|
+
expect(generalDocHref("legal", "9")).toBe("/knowledge/laws/9");
|
|
52
|
+
expect(generalDocHref("summary", "9")).toBe("/knowledge/summary");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,348 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
return async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return api;
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
|
|
42
|
+
|
|
43
|
+
const mocks = vi.hoisted(() => ({
|
|
44
|
+
currentSession: vi.fn(),
|
|
45
|
+
wsFindUnique: vi.fn(),
|
|
46
|
+
wkbFindMany: vi.fn(),
|
|
47
|
+
wkbDeleteMany: vi.fn(),
|
|
48
|
+
wkbCreateMany: vi.fn(),
|
|
49
|
+
wqdFindMany: vi.fn(),
|
|
50
|
+
wqdFindUnique: vi.fn(),
|
|
51
|
+
wqdUpdate: vi.fn(),
|
|
52
|
+
wqdDelete: vi.fn(),
|
|
53
|
+
wqdAggregate: vi.fn(),
|
|
54
|
+
wqdCreate: vi.fn(),
|
|
55
|
+
wqdFindFirst: vi.fn(),
|
|
56
|
+
legalFindMany: vi.fn(),
|
|
57
|
+
kbFindMany: vi.fn(),
|
|
58
|
+
kbFindFirst: vi.fn(),
|
|
59
|
+
transaction: vi.fn(),
|
|
60
|
+
docFindMany: vi.fn(),
|
|
61
|
+
docFindFirst: vi.fn(),
|
|
62
|
+
articleFavoriteFindMany: vi.fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock("@allbluecn/database", () => {
|
|
66
|
+
const prisma = {
|
|
67
|
+
workspace: { findUnique: mocks.wsFindUnique },
|
|
68
|
+
workspaceKnowledgeBase: { findMany: mocks.wkbFindMany, deleteMany: mocks.wkbDeleteMany, createMany: mocks.wkbCreateMany },
|
|
69
|
+
workspaceQuickDoc: {
|
|
70
|
+
findMany: mocks.wqdFindMany,
|
|
71
|
+
findUnique: mocks.wqdFindUnique,
|
|
72
|
+
update: mocks.wqdUpdate,
|
|
73
|
+
delete: mocks.wqdDelete,
|
|
74
|
+
aggregate: mocks.wqdAggregate,
|
|
75
|
+
create: mocks.wqdCreate,
|
|
76
|
+
findFirst: mocks.wqdFindFirst,
|
|
77
|
+
},
|
|
78
|
+
legalRegulation: { findMany: mocks.legalFindMany },
|
|
79
|
+
knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
|
|
80
|
+
knowledgeDocument: { findMany: mocks.docFindMany, findFirst: mocks.docFindFirst },
|
|
81
|
+
articleFavorite: { findMany: mocks.articleFavoriteFindMany },
|
|
82
|
+
$transaction: mocks.transaction,
|
|
83
|
+
};
|
|
84
|
+
return { prisma, Prisma: {} };
|
|
85
|
+
});
|
|
86
|
+
vi.mock("@/server/auth-helpers", () => ({ requireAuth: mocks.currentSession }));
|
|
87
|
+
|
|
88
|
+
// 默认 transaction mock:调用回调并透传 tx 对象
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
mocks.transaction.mockImplementation(async (fn: (tx: any) => any) =>
|
|
91
|
+
fn({ workspaceKnowledgeBase: { deleteMany: mocks.wkbDeleteMany, createMany: mocks.wkbCreateMany } }),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
import {
|
|
96
|
+
getWorkspaceKbCardFn,
|
|
97
|
+
listWorkspaceAvailableKbsFn,
|
|
98
|
+
setWorkspaceKbsFn,
|
|
99
|
+
searchQuickDocCandidatesFn,
|
|
100
|
+
addQuickDocsFn,
|
|
101
|
+
renameQuickDocFn,
|
|
102
|
+
removeQuickDocFn,
|
|
103
|
+
reorderQuickDocsFn,
|
|
104
|
+
} from "../workspace-kb";
|
|
105
|
+
|
|
106
|
+
const MEMBER_WS = { ownerId: "u1", members: [] };
|
|
107
|
+
const MEMBER_AS_MEMBER = { ownerId: "other", members: [{ id: "m1" }] };
|
|
108
|
+
const NON_MEMBER = { ownerId: "other", members: [] };
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("getWorkspaceKbCardFn", () => {
|
|
116
|
+
it("非成员拒绝", async () => {
|
|
117
|
+
mocks.wsFindUnique.mockResolvedValue(NON_MEMBER);
|
|
118
|
+
await expect(
|
|
119
|
+
getWorkspaceKbCardFn({ data: { workspaceId: "ws1" } }),
|
|
120
|
+
).rejects.toThrow("成员");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("返回 kbs + quickDocs(doc 态带 kb 信息、ext 态标题兜底)", async () => {
|
|
124
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
125
|
+
mocks.wkbFindMany.mockResolvedValue([
|
|
126
|
+
{ kb: { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" } },
|
|
127
|
+
]);
|
|
128
|
+
mocks.wqdFindMany.mockResolvedValue([
|
|
129
|
+
{
|
|
130
|
+
id: "q1", displayTitle: "接入指南", sortOrder: 0, docId: "d1",
|
|
131
|
+
externalGroup: null, externalId: null,
|
|
132
|
+
doc: { title: "接入指南", icon: "doc", isDeleted: false, kb: { id: "kb1", name: "团队库" } },
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "q2", displayTitle: "数据安全法", sortOrder: 1, docId: null,
|
|
136
|
+
externalGroup: "legal", externalId: "x9",
|
|
137
|
+
doc: null,
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
mocks.legalFindMany.mockResolvedValue([{ id: "x9", title: "数据安全法(现行)" }]);
|
|
141
|
+
const res = await getWorkspaceKbCardFn({ data: { workspaceId: "ws1" } });
|
|
142
|
+
expect(res.kbs).toHaveLength(1);
|
|
143
|
+
expect(res.quickDocs[0]).toMatchObject({ source: "doc", kbId: "kb1", stale: false });
|
|
144
|
+
expect(res.quickDocs[1]).toMatchObject({
|
|
145
|
+
source: "general", group: "legal", externalId: "x9",
|
|
146
|
+
sourceTitle: "数据安全法(现行)", stale: false,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("外部条目源已删除 → stale: true 且仍返回", async () => {
|
|
151
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_AS_MEMBER);
|
|
152
|
+
mocks.wkbFindMany.mockResolvedValue([]);
|
|
153
|
+
mocks.wqdFindMany.mockResolvedValue([
|
|
154
|
+
{ id: "q3", displayTitle: "旧法条", sortOrder: 0, docId: null, externalGroup: "legal", externalId: "gone", doc: null },
|
|
155
|
+
]);
|
|
156
|
+
mocks.legalFindMany.mockResolvedValue([]); // 源查不到
|
|
157
|
+
const res = await getWorkspaceKbCardFn({ data: { workspaceId: "ws1" } });
|
|
158
|
+
expect(res.quickDocs[0]).toMatchObject({ stale: true, displayTitle: "旧法条" });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("listWorkspaceAvailableKbsFn", () => {
|
|
163
|
+
it("返回已关联 ∪ 用户 team 库,带 linked 标记", async () => {
|
|
164
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
165
|
+
mocks.wkbFindMany.mockResolvedValue([{ kbId: "kbLinked" }]);
|
|
166
|
+
mocks.kbFindMany.mockResolvedValue([
|
|
167
|
+
{ id: "kbLinked", name: "他人的库", icon: "notebook", visibility: "team" },
|
|
168
|
+
{ id: "kbMine", name: "我的库", icon: "notebook", visibility: "team" },
|
|
169
|
+
]);
|
|
170
|
+
const res = await listWorkspaceAvailableKbsFn({ data: { workspaceId: "ws1" } });
|
|
171
|
+
expect(res.find((k: any) => k.id === "kbLinked")?.linked).toBe(true);
|
|
172
|
+
expect(res.find((k: any) => k.id === "kbMine")?.linked).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("setWorkspaceKbsFn", () => {
|
|
177
|
+
it("非 team 库拒绝且不写库", async () => {
|
|
178
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
179
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]); // 请求 2 个只查回 1 个
|
|
180
|
+
await expect(
|
|
181
|
+
setWorkspaceKbsFn({ data: { workspaceId: "ws1", kbIds: ["kb1", "kb2"] } }),
|
|
182
|
+
).rejects.toThrow("团队可见");
|
|
183
|
+
expect(mocks.transaction).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("全量同步:删差集 + 增新增", async () => {
|
|
187
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
188
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]);
|
|
189
|
+
await setWorkspaceKbsFn({ data: { workspaceId: "ws1", kbIds: ["kb1", "kb2"] } });
|
|
190
|
+
expect(mocks.wkbDeleteMany).toHaveBeenCalledWith({
|
|
191
|
+
where: { workspaceId: "ws1", kbId: { notIn: ["kb1", "kb2"] } },
|
|
192
|
+
});
|
|
193
|
+
expect(mocks.wkbCreateMany).toHaveBeenCalledWith({
|
|
194
|
+
data: [
|
|
195
|
+
{ workspaceId: "ws1", kbId: "kb1" },
|
|
196
|
+
{ workspaceId: "ws1", kbId: "kb2" },
|
|
197
|
+
],
|
|
198
|
+
skipDuplicates: true,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("searchQuickDocCandidatesFn", () => {
|
|
204
|
+
it("kbId 态返回该库文档树(须属用户 team 库)", async () => {
|
|
205
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
206
|
+
mocks.kbFindFirst.mockResolvedValue({ id: "kb1" });
|
|
207
|
+
mocks.docFindMany.mockResolvedValue([
|
|
208
|
+
{ id: "d1", title: "接入指南", parentId: null, icon: "doc", kb: { id: "kb1", name: "团队库", icon: "notebook" } },
|
|
209
|
+
]);
|
|
210
|
+
const res = await searchQuickDocCandidatesFn({
|
|
211
|
+
data: { workspaceId: "ws1", kbId: "kb1", query: "" },
|
|
212
|
+
});
|
|
213
|
+
expect(res.items[0]).toMatchObject({ id: "d1", kbId: "kb1", parentId: null });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("generalGroup 态走通用表(含 ext 前缀 id)", async () => {
|
|
217
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
218
|
+
mocks.articleFavoriteFindMany.mockResolvedValue([]);
|
|
219
|
+
mocks.legalFindMany.mockResolvedValue([{ id: "x9", title: "数据安全法" }]);
|
|
220
|
+
const res = await searchQuickDocCandidatesFn({
|
|
221
|
+
data: { workspaceId: "ws1", generalGroup: "legal", query: "" },
|
|
222
|
+
});
|
|
223
|
+
expect(res.items[0]).toMatchObject({ id: "ext:legal:x9", title: "数据安全法" });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("addQuickDocsFn", () => {
|
|
228
|
+
it("互斥违规被 validator 拒绝", async () => {
|
|
229
|
+
await expect(
|
|
230
|
+
addQuickDocsFn({
|
|
231
|
+
data: {
|
|
232
|
+
workspaceId: "ws1",
|
|
233
|
+
items: [{ docId: "d1", externalGroup: "legal", externalId: "x1", displayTitle: "x" }],
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
).rejects.toThrow("二选一");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("文档不存在 → failed outOfScope;存在 → 创建且落到最短列末尾", async () => {
|
|
240
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
241
|
+
// 现有布局:col0 有 2 行(max sortOrder 1)、col1 有 1 行(max sortOrder 0)
|
|
242
|
+
mocks.wqdFindMany.mockResolvedValue([
|
|
243
|
+
{ columnIndex: 0, sortOrder: 0 },
|
|
244
|
+
{ columnIndex: 0, sortOrder: 1 },
|
|
245
|
+
{ columnIndex: 1, sortOrder: 0 },
|
|
246
|
+
]);
|
|
247
|
+
mocks.docFindFirst.mockResolvedValueOnce(null) // d1 不存在
|
|
248
|
+
.mockResolvedValueOnce({ id: "d2" }); // d2 存在
|
|
249
|
+
mocks.wqdFindFirst.mockResolvedValue(null); // 无重复
|
|
250
|
+
mocks.wqdCreate.mockImplementation(async (args: any) => ({ id: "new-" + args.data.docId }));
|
|
251
|
+
const res = await addQuickDocsFn({
|
|
252
|
+
data: {
|
|
253
|
+
workspaceId: "ws1",
|
|
254
|
+
items: [
|
|
255
|
+
{ docId: "d1", displayTitle: "失效文档" },
|
|
256
|
+
{ docId: "d2", displayTitle: "有效文档" },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
expect(res.failed).toEqual([{ key: "d1", reason: "outOfScope" }]);
|
|
261
|
+
expect(res.created).toEqual(["d2"]);
|
|
262
|
+
// col2/3/4 为空列(0 行)比 col1(1 行)更短,新文档落到第一个空列 col2 末尾
|
|
263
|
+
expect(mocks.wqdCreate).toHaveBeenCalledWith(
|
|
264
|
+
expect.objectContaining({
|
|
265
|
+
data: expect.objectContaining({ docId: "d2", columnIndex: 2, sortOrder: 0 }),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("summary 组外部条目拒绝(无实体表)", async () => {
|
|
271
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
272
|
+
const res = await addQuickDocsFn({
|
|
273
|
+
data: { workspaceId: "ws1", items: [{ externalGroup: "summary", externalId: "s1", displayTitle: "周报" }] },
|
|
274
|
+
});
|
|
275
|
+
expect(res.failed).toEqual([{ key: "ext:summary:s1", reason: "outOfScope" }]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("renameQuickDocFn", () => {
|
|
280
|
+
it("非本工作空间成员拒绝", async () => {
|
|
281
|
+
mocks.wqdFindUnique.mockResolvedValue({ workspaceId: "ws1" });
|
|
282
|
+
mocks.wsFindUnique.mockResolvedValue(NON_MEMBER);
|
|
283
|
+
await expect(
|
|
284
|
+
renameQuickDocFn({ data: { quickDocId: "q1", displayTitle: "新标题" } }),
|
|
285
|
+
).rejects.toThrow("成员");
|
|
286
|
+
expect(mocks.wqdUpdate).not.toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
it("成员重命名成功", async () => {
|
|
289
|
+
mocks.wqdFindUnique.mockResolvedValue({ workspaceId: "ws1" });
|
|
290
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
291
|
+
await renameQuickDocFn({ data: { quickDocId: "q1", displayTitle: "新标题" } });
|
|
292
|
+
expect(mocks.wqdUpdate).toHaveBeenCalledWith({
|
|
293
|
+
where: { id: "q1" },
|
|
294
|
+
data: { displayTitle: "新标题" },
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("removeQuickDocFn", () => {
|
|
300
|
+
it("成员删除成功", async () => {
|
|
301
|
+
mocks.wqdFindUnique.mockResolvedValue({ workspaceId: "ws1" });
|
|
302
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_AS_MEMBER);
|
|
303
|
+
await removeQuickDocFn({ data: { quickDocId: "q1" } });
|
|
304
|
+
expect(mocks.wqdDelete).toHaveBeenCalledWith({ where: { id: "q1" } });
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("reorderQuickDocsFn", () => {
|
|
309
|
+
it("包含非本工作空间 id 拒绝", async () => {
|
|
310
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
311
|
+
mocks.wqdFindMany.mockResolvedValue([{ id: "q1" }]); // 请求 2 个只属 1 个
|
|
312
|
+
await expect(
|
|
313
|
+
reorderQuickDocsFn({
|
|
314
|
+
data: {
|
|
315
|
+
workspaceId: "ws1",
|
|
316
|
+
items: [
|
|
317
|
+
{ id: "q1", columnIndex: 0, sortOrder: 0 },
|
|
318
|
+
{ id: "qX", columnIndex: 1, sortOrder: 0 },
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
}),
|
|
322
|
+
).rejects.toThrow("不属于");
|
|
323
|
+
});
|
|
324
|
+
it("按传入的列归属与顺序重写 columnIndex/sortOrder", async () => {
|
|
325
|
+
mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
|
|
326
|
+
mocks.wqdFindMany.mockResolvedValue([{ id: "q1" }, { id: "q2" }]);
|
|
327
|
+
const updates: any[] = [];
|
|
328
|
+
mocks.wqdUpdate.mockImplementation(async (args: any) => { updates.push(args); return args; });
|
|
329
|
+
mocks.transaction.mockImplementation(async (ops: any[]) => Promise.all(ops.map((op) => op)));
|
|
330
|
+
await reorderQuickDocsFn({
|
|
331
|
+
data: {
|
|
332
|
+
workspaceId: "ws1",
|
|
333
|
+
items: [
|
|
334
|
+
{ id: "q2", columnIndex: 1, sortOrder: 0 },
|
|
335
|
+
{ id: "q1", columnIndex: 2, sortOrder: 3 },
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
expect(mocks.wqdUpdate).toHaveBeenNthCalledWith(1, {
|
|
340
|
+
where: { id: "q2" },
|
|
341
|
+
data: { columnIndex: 1, sortOrder: 0 },
|
|
342
|
+
});
|
|
343
|
+
expect(mocks.wqdUpdate).toHaveBeenNthCalledWith(2, {
|
|
344
|
+
where: { id: "q1" },
|
|
345
|
+
data: { columnIndex: 2, sortOrder: 3 },
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -68,6 +68,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
68
68
|
knowledgeBaseDeleteMany: vi.fn(),
|
|
69
69
|
knowledgeBaseUpdateMany: vi.fn(),
|
|
70
70
|
knowledgeBaseCreate: vi.fn(),
|
|
71
|
+
wkbCreate: vi.fn(),
|
|
71
72
|
usageRecordDeleteMany: vi.fn(),
|
|
72
73
|
aiUsageLogDeleteMany: vi.fn(),
|
|
73
74
|
ssoProviderDeleteMany: vi.fn(),
|
|
@@ -132,6 +133,9 @@ vi.mock("@allbluecn/database", () => ({
|
|
|
132
133
|
deleteMany: mocks.knowledgeBaseDeleteMany,
|
|
133
134
|
updateMany: mocks.knowledgeBaseUpdateMany,
|
|
134
135
|
},
|
|
136
|
+
workspaceKnowledgeBase: {
|
|
137
|
+
create: mocks.wkbCreate,
|
|
138
|
+
},
|
|
135
139
|
usageRecord: {
|
|
136
140
|
deleteMany: mocks.usageRecordDeleteMany,
|
|
137
141
|
},
|
|
@@ -161,6 +165,9 @@ vi.mock("@allbluecn/database", () => ({
|
|
|
161
165
|
deleteMany: mocks.knowledgeBaseDeleteMany,
|
|
162
166
|
updateMany: mocks.knowledgeBaseUpdateMany,
|
|
163
167
|
},
|
|
168
|
+
workspaceKnowledgeBase: {
|
|
169
|
+
create: mocks.wkbCreate,
|
|
170
|
+
},
|
|
164
171
|
usageRecord: {
|
|
165
172
|
deleteMany: mocks.usageRecordDeleteMany,
|
|
166
173
|
},
|
|
@@ -215,6 +222,7 @@ describe("createWorkspaceFn", () => {
|
|
|
215
222
|
it("创建工作空间后 owner 即为所有者", async () => {
|
|
216
223
|
const ws = { id: "ws1", name: "测试空间", icon: "folder", ownerId: "u1" };
|
|
217
224
|
mocks.workspaceCreate.mockResolvedValue(ws);
|
|
225
|
+
mocks.knowledgeBaseCreate.mockResolvedValue({ id: "kb1" });
|
|
218
226
|
|
|
219
227
|
const result = await createWorkspaceFn({
|
|
220
228
|
data: { name: "测试空间", icon: "folder", description: "介绍" },
|
|
@@ -231,7 +239,7 @@ describe("createWorkspaceFn", () => {
|
|
|
231
239
|
|
|
232
240
|
await expect(
|
|
233
241
|
createWorkspaceFn({ data: { name: "测试" } }),
|
|
234
|
-
).rejects.
|
|
242
|
+
).rejects.toThrow("未认证");
|
|
235
243
|
});
|
|
236
244
|
|
|
237
245
|
it("名称为空时 zod validator 拦截", async () => {
|
|
@@ -239,6 +247,18 @@ describe("createWorkspaceFn", () => {
|
|
|
239
247
|
createWorkspaceFn({ data: { name: "", icon: "folder" } }),
|
|
240
248
|
).rejects.toThrow();
|
|
241
249
|
});
|
|
250
|
+
|
|
251
|
+
it("创建工作空间时事务内自动关联同步创建的知识库", async () => {
|
|
252
|
+
mocks.knowledgeBaseCreate.mockResolvedValue({ id: "kb1" });
|
|
253
|
+
const ws = { id: "ws1", name: "测试空间", icon: "folder", ownerId: "u1" };
|
|
254
|
+
mocks.workspaceCreate.mockResolvedValue(ws);
|
|
255
|
+
mocks.wkbCreate.mockResolvedValue({ id: "wkb1" });
|
|
256
|
+
|
|
257
|
+
await createWorkspaceFn({ data: { name: "测试空间" } });
|
|
258
|
+
expect(mocks.wkbCreate).toHaveBeenCalledWith({
|
|
259
|
+
data: { workspaceId: "ws1", kbId: "kb1" },
|
|
260
|
+
});
|
|
261
|
+
});
|
|
242
262
|
});
|
|
243
263
|
|
|
244
264
|
describe("listWorkspacesFn", () => {
|
|
@@ -312,7 +332,7 @@ describe("getWorkspaceDetailFn", () => {
|
|
|
312
332
|
|
|
313
333
|
await expect(
|
|
314
334
|
getWorkspaceDetailFn({ data: { workspaceId: "ws1" } }),
|
|
315
|
-
).rejects.
|
|
335
|
+
).rejects.toThrow("工作空间不存在");
|
|
316
336
|
expect(mocks.workspaceFindUnique).toHaveBeenCalledTimes(1);
|
|
317
337
|
});
|
|
318
338
|
|
|
@@ -321,7 +341,7 @@ describe("getWorkspaceDetailFn", () => {
|
|
|
321
341
|
|
|
322
342
|
await expect(
|
|
323
343
|
getWorkspaceDetailFn({ data: { workspaceId: "ws1" } }),
|
|
324
|
-
).rejects.
|
|
344
|
+
).rejects.toThrow("仅工作空间所有者可访问");
|
|
325
345
|
});
|
|
326
346
|
});
|
|
327
347
|
|
|
@@ -350,7 +370,7 @@ describe("listWorkspaceProjectsFn", () => {
|
|
|
350
370
|
|
|
351
371
|
await expect(
|
|
352
372
|
listWorkspaceProjectsFn({ data: { workspaceId: "ws1" } }),
|
|
353
|
-
).rejects.
|
|
373
|
+
).rejects.toThrow("仅工作空间所有者可访问");
|
|
354
374
|
});
|
|
355
375
|
});
|
|
356
376
|
|
|
@@ -443,7 +463,7 @@ describe("deleteWorkspaceFn", () => {
|
|
|
443
463
|
|
|
444
464
|
await expect(
|
|
445
465
|
deleteWorkspaceFn({ data: { workspaceId: "ws1", mode: "transfer" } }),
|
|
446
|
-
).rejects.
|
|
466
|
+
).rejects.toThrow("转移模式必须指定目标工作空间");
|
|
447
467
|
expect(mocks.workspaceDelete).not.toHaveBeenCalled();
|
|
448
468
|
});
|
|
449
469
|
|
|
@@ -455,7 +475,7 @@ describe("deleteWorkspaceFn", () => {
|
|
|
455
475
|
deleteWorkspaceFn({
|
|
456
476
|
data: { workspaceId: "ws1", mode: "transfer", targetWorkspaceId: "ws1" },
|
|
457
477
|
}),
|
|
458
|
-
).rejects.
|
|
478
|
+
).rejects.toThrow("不可转移到自身");
|
|
459
479
|
expect(mocks.workspaceDelete).not.toHaveBeenCalled();
|
|
460
480
|
});
|
|
461
481
|
|
|
@@ -482,7 +502,7 @@ describe("deleteWorkspaceFn", () => {
|
|
|
482
502
|
|
|
483
503
|
await expect(
|
|
484
504
|
deleteWorkspaceFn({ data: { workspaceId: "ws1" } }),
|
|
485
|
-
).rejects.
|
|
505
|
+
).rejects.toThrow("仅工作空间所有者可访问");
|
|
486
506
|
});
|
|
487
507
|
});
|
|
488
508
|
|
|
@@ -514,6 +534,6 @@ describe("updateWorkspaceFn", () => {
|
|
|
514
534
|
updateWorkspaceFn({
|
|
515
535
|
data: { workspaceId: "ws1", name: "新名称" },
|
|
516
536
|
}),
|
|
517
|
-
).rejects.
|
|
537
|
+
).rejects.toThrow("仅工作空间所有者可访问");
|
|
518
538
|
});
|
|
519
539
|
});
|
|
@@ -0,0 +1,132 @@
|
|
|
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 { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
20
|
+
import { z } from 'zod'
|
|
21
|
+
import { prisma } from '@allbluecn/database'
|
|
22
|
+
import { getCurrentSession } from '@/lib/session'
|
|
23
|
+
import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
|
|
24
|
+
import { sendTemplateEmail, generateEmailToken } from '@/server/email'
|
|
25
|
+
|
|
26
|
+
const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
const appUrl = () => process.env.VITE_APP_URL ?? 'http://localhost:3000'
|
|
29
|
+
|
|
30
|
+
export const confirmEmailVerificationFn = createServerFn({ method: 'GET' })
|
|
31
|
+
.validator(z.object({ token: z.string().min(1) }))
|
|
32
|
+
.handler(async ({ data }) => {
|
|
33
|
+
const row = await prisma.emailVerificationToken.findUnique({
|
|
34
|
+
where: { token: data.token },
|
|
35
|
+
})
|
|
36
|
+
if (!row || row.usedAt || new Date() > row.expiresAt) {
|
|
37
|
+
return { ok: false as const }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const claimed = await prisma.$transaction(async (tx) => {
|
|
41
|
+
const claim = await tx.emailVerificationToken.updateMany({
|
|
42
|
+
where: { id: row.id, usedAt: null },
|
|
43
|
+
data: { usedAt: new Date() },
|
|
44
|
+
})
|
|
45
|
+
if (claim.count === 0) return false
|
|
46
|
+
await tx.user.update({
|
|
47
|
+
where: { id: row.userId },
|
|
48
|
+
data: { emailVerified: new Date() },
|
|
49
|
+
})
|
|
50
|
+
return true
|
|
51
|
+
})
|
|
52
|
+
if (!claimed) return { ok: false as const }
|
|
53
|
+
|
|
54
|
+
const user = await prisma.user.findUnique({
|
|
55
|
+
where: { id: row.userId },
|
|
56
|
+
select: { email: true, locale: true },
|
|
57
|
+
})
|
|
58
|
+
if (user) {
|
|
59
|
+
await sendTemplateEmail({
|
|
60
|
+
key: 'auth.welcome',
|
|
61
|
+
to: user.email,
|
|
62
|
+
locale: user.locale ?? 'zh',
|
|
63
|
+
userId: row.userId,
|
|
64
|
+
vars: { dashboardUrl: `${appUrl()}/dashboard` },
|
|
65
|
+
}).catch((err) => {
|
|
66
|
+
console.error('welcome email failed', {
|
|
67
|
+
userId: row.userId,
|
|
68
|
+
templateKey: 'auth.welcome',
|
|
69
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { ok: true as const }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export const resendVerificationEmailFn = createServerFn({ method: 'POST' })
|
|
78
|
+
.validator(z.object({}))
|
|
79
|
+
.handler(async () => {
|
|
80
|
+
const request = getRequest()
|
|
81
|
+
const session = await getCurrentSession(request)
|
|
82
|
+
const userId = session?.user?.id
|
|
83
|
+
if (!userId) throw new Error('请先登录')
|
|
84
|
+
|
|
85
|
+
await checkAuthRateLimit('resendVerify', 5, 15 * 60 * 1000)
|
|
86
|
+
|
|
87
|
+
const user = await prisma.user.findUnique({
|
|
88
|
+
where: { id: userId },
|
|
89
|
+
select: { email: true, username: true, emailVerified: true, locale: true },
|
|
90
|
+
})
|
|
91
|
+
if (!user) throw new Error('用户不存在')
|
|
92
|
+
if (user.emailVerified) return { alreadyVerified: true }
|
|
93
|
+
|
|
94
|
+
const token = generateEmailToken()
|
|
95
|
+
await prisma.emailVerificationToken.create({
|
|
96
|
+
data: {
|
|
97
|
+
token,
|
|
98
|
+
userId,
|
|
99
|
+
expiresAt: new Date(Date.now() + VERIFY_TOKEN_TTL_MS),
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await sendTemplateEmail({
|
|
104
|
+
key: 'auth.verify-email',
|
|
105
|
+
to: user.email,
|
|
106
|
+
locale: user.locale ?? 'zh',
|
|
107
|
+
userId,
|
|
108
|
+
vars: { url: `${appUrl()}/verify-email?token=${token}`, username: user.username },
|
|
109
|
+
}).catch((err) => {
|
|
110
|
+
console.error('resend verification email failed', {
|
|
111
|
+
userId,
|
|
112
|
+
templateKey: 'auth.verify-email',
|
|
113
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return { alreadyVerified: false }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export const getEmailVerifiedStatusFn = createServerFn({ method: 'GET' })
|
|
121
|
+
.validator(z.object({}))
|
|
122
|
+
.handler(async () => {
|
|
123
|
+
const request = getRequest()
|
|
124
|
+
const session = await getCurrentSession(request)
|
|
125
|
+
if (!session?.user?.id) return { verified: null as boolean | null }
|
|
126
|
+
|
|
127
|
+
const user = await prisma.user.findUnique({
|
|
128
|
+
where: { id: session.user.id },
|
|
129
|
+
select: { emailVerified: true },
|
|
130
|
+
})
|
|
131
|
+
return { verified: !!user?.emailVerified }
|
|
132
|
+
})
|