diff --git a/docs/adr/0032-remove-recent-visit-module.md b/docs/adr/0032-remove-recent-visit-module.md new file mode 100644 index 0000000..6cd24a3 --- /dev/null +++ b/docs/adr/0032-remove-recent-visit-module.md @@ -0,0 +1,30 @@ +# ADR 0032: Remove The Recent-Visit Module + +## Status + +Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half +is unaffected and remains in force). + +## Context + +ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven +visit recording, and a rail entry in the teacher app. After seeing it live, the product +call is that the module is not wanted — it adds a tracking surface, a table, and rail +noise without a compelling teacher workflow behind it. + +## Decision + +The recent-visit module is removed end-to-end: + +- `FileLibRecentVisit` is dropped (hand-written migration + `20260731090000_drop_filelib_recent_visit`; the table was created the same day and + held no production data). +- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView` + component are deleted; the rail in `/app` keeps only 文件库 / 回收站. +- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it. +- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a + cheap, additive field on an existing API and independent of the removed module. + +If recent-visit tracking comes back as a requirement, it is a new decision (and +should then define why client-driven tracking is worth its surface) rather than a +revival of this one. diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 3e064bf..8e9c90a 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -23,14 +23,6 @@ import GrantsPanel from "./GrantsPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte"; - /** 最近打开上报的导航目标(ADR-0031):父组件传入后,本组件跳到对应节点并清除。 */ - export interface NavTarget { - readonly nodeId: string; - readonly filePath?: string | undefined; - } - - let { navTarget = null, onnavigated }: { navTarget?: NavTarget | null; onnavigated?: () => void } = $props(); - const RANK: Record = { VIEW: 1, EDIT: 2, MANAGE: 3 }; const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]; @@ -107,14 +99,6 @@ onMount(loadChildren); - /** 最近打开上报(ADR-0031):fire-and-forget,失败静默,不阻塞浏览。 */ - function record(nodeId: string, filePath?: string): void { - void api("/database/api/recent", { - method: "POST", - body: { nodeId, ...(filePath !== undefined ? { filePath } : {}) }, - }).catch(() => undefined); - } - function refresh(): void { selected = null; menu = null; @@ -126,7 +110,6 @@ function openNode(n: StackItem): void { selected = null; - record(n.id); if (n.kind === "FOLDER") { stack = [...stack, n]; void loadChildren(); @@ -174,43 +157,6 @@ void loadChildren(); } - /** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */ - async function navigateTo(target: NavTarget): Promise { - try { - const r = await api<{ breadcrumb: Array<{ id: string | null; name: string | null; kind: "FOLDER" | "PROJECT"; role: Role | null }> }>( - `/database/api/nodes/${target.nodeId}/breadcrumb`, - ); - const visible = r.breadcrumb.filter( - (e): e is { id: string; name: string; kind: "FOLDER" | "PROJECT"; role: Role | null } => - e.id !== null && e.name !== null, - ); - if (visible.length === 0) return; - const self = visible[visible.length - 1]!; - selected = null; - if (self.kind === "FOLDER") { - view = "nodes"; - projectNode = null; - clearSelectedFile(); - stack = visible.map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" })); - await loadChildren(); - } else { - stack = visible.slice(0, -1).map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" })); - projectNode = await fetchDetail(self.id); - view = "files"; - await loadFiles(); - if (target.filePath !== undefined) selectedFilePath.set(target.filePath); - } - } catch (e) { - toastErr(errText(e)); - } - } - - $effect(() => { - if (navTarget === null) return; - const t = navTarget; - void navigateTo(t).finally(() => onnavigated?.()); - }); - /* ------------------------------------------------------------ 节点操作 */ function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void { @@ -300,7 +246,6 @@ /* ------------------------------------------------------------ 文件操作 */ function previewFile(f: FileEntry): void { - if (projectNode !== null) record(projectNode.id, f.path); selectedFilePath.set(f.path); } diff --git a/hub/filelib-web/src/lib/RecentView.svelte b/hub/filelib-web/src/lib/RecentView.svelte deleted file mode 100644 index 7f2e33d..0000000 --- a/hub/filelib-web/src/lib/RecentView.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -
-

最近打开

- - {#if error !== null} -
{error}
- {:else if entries === null} -
加载中…
- {:else if entries.length === 0} -
还没有访问记录 · 去文件库逛逛
- {:else} -
- {#each entries as e (e.nodeId + "/" + e.filePath)} - - {/each} -
- {/if} -
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 29a0e8f..ad07726 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -132,16 +132,6 @@ export interface UserSearchResult { readonly avatarUrl: string | null; } -/** 最近打开条目(GET /database/api/recent)。 */ -export interface RecentEntry { - readonly nodeId: string; - readonly kind: NodeKind; - readonly name: string; - /** "" = 节点本身;非空 = 项目内文件路径。 */ - readonly filePath: string; - readonly openedAt: string; -} - /** 回收站条目(GET /database/api/bin)。 */ export interface BinEntry { readonly id: string; diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte index ac106b0..9cb2e19 100644 --- a/hub/filelib-web/src/routes/app/+page.svelte +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -4,25 +4,17 @@ import { me, authChecked } from "$lib/stores.js"; import { loadSession } from "$lib/session.js"; import LoginView from "$lib/LoginView.svelte"; - import GridLibraryView, { type NavTarget } from "$lib/GridLibraryView.svelte"; - import RecentView from "$lib/RecentView.svelte"; + import GridLibraryView from "$lib/GridLibraryView.svelte"; import BinView from "$lib/BinView.svelte"; import Icon from "$lib/Icon.svelte"; onMount(loadSession); - type View = "library" | "recent" | "bin"; + type View = "library" | "bin"; let view = $state("library"); - let navTarget = $state(null); - function openFromRecent(target: NavTarget): void { - navTarget = target; - view = "library"; - } - - const tabs: ReadonlyArray = [ + const tabs: ReadonlyArray = [ ["library", "文件库", "layers"], - ["recent", "最近打开", "clock"], ["bin", "回收站", "trash"], ]; @@ -49,9 +41,7 @@ {#if view === "library"} - (navTarget = null)} /> - {:else if view === "recent"} - + {:else} {/if} diff --git a/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql b/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql new file mode 100644 index 0000000..d7a66d3 --- /dev/null +++ b/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql @@ -0,0 +1,2 @@ +-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。 +DROP TABLE "FileLibRecentVisit"; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index ffd6828..0779fa9 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -1110,18 +1110,3 @@ model FileLibExportJob { @@index([organizationId, status]) } - -/// ADR-0031:最近打开。客户端在成功打开后上报;filePath="" 表示节点本身 -/// (文件夹/项目),非空表示项目内文件预览(PG 唯一索引视 NULL 互不相同, -/// 故用空串而非 null)。名称读取时 join FileLibNode 实时取,不做冗余。 -model FileLibRecentVisit { - id String @id @default(cuid()) - organizationId String - userId String - nodeId String - filePath String @default("") - openedAt DateTime - - @@unique([organizationId, userId, nodeId, filePath]) - @@index([organizationId, userId, openedAt]) -} diff --git a/hub/src/database/filelib/recentService.ts b/hub/src/database/filelib/recentService.ts deleted file mode 100644 index c494d94..0000000 --- a/hub/src/database/filelib/recentService.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 最近打开(ADR-0031)。 - * - * 记录:客户端在成功打开后上报;node 需 VIEW(D8,无权即 404 不泄露); - * upsert 语义 —— 重复打开只刷新 openedAt。不写审计(按用户的读模型, - * 非权限敏感写)。 - * 列表:本人最近 20 条,openedAt 倒序;节点已删或**任一祖先已删**的条目 - * 过滤掉(D8/D15 可见性在每个面都成立);名称 join FileLibNode 实时取。 - */ - -import type { PrismaClient } from "@prisma/client"; -import { requireAccessInTx, type AccessDeps, type FileLibActor } from "./treeService.js"; - -export type RecentDeps = AccessDeps & { readonly prisma: PrismaClient }; - -export interface RecentEntryDto { - readonly nodeId: string; - readonly kind: "FOLDER" | "PROJECT"; - readonly name: string; - /** "" = 节点本身;非空 = 项目内文件路径。 */ - readonly filePath: string; - readonly openedAt: Date; -} - -const RECENT_LIMIT = 20; - -export async function recordVisit( - deps: RecentDeps, - actor: FileLibActor, - nodeId: string, - filePath: string | undefined, -): Promise { - const path = filePath ?? ""; - await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, nodeId, "VIEW")); - await deps.prisma.fileLibRecentVisit.upsert({ - where: { - organizationId_userId_nodeId_filePath: { - organizationId: deps.organizationId, - userId: actor.userId, - nodeId, - filePath: path, - }, - }, - update: { openedAt: new Date() }, - create: { - organizationId: deps.organizationId, - userId: actor.userId, - nodeId, - filePath: path, - openedAt: new Date(), - }, - }); -} - -export async function listRecent(deps: RecentDeps, actor: FileLibActor): Promise { - // 可见性过滤会丢弃一部分,超取再截断。 - const rows = await deps.prisma.fileLibRecentVisit.findMany({ - where: { organizationId: deps.organizationId, userId: actor.userId }, - orderBy: { openedAt: "desc" }, - take: RECENT_LIMIT * 3, - }); - if (rows.length === 0) return []; - - const nodeIds = [...new Set(rows.map((r) => r.nodeId))]; - const nodes = await deps.prisma.fileLibNode.findMany({ - where: { id: { in: nodeIds } }, - select: { id: true, kind: true, name: true, pathIds: true, deletedAt: true }, - }); - const byId = new Map(nodes.map((n) => [n.id, n])); - - // 祖先活跃性:收集所有节点的祖先段,查已删集合。 - const ancestorIds = new Set(); - for (const n of nodes) { - for (const s of n.pathIds.split("/").filter((x) => x !== "" && x !== n.id)) ancestorIds.add(s); - } - const deletedAncestorIds = new Set( - ancestorIds.size === 0 - ? [] - : ( - await deps.prisma.fileLibNode.findMany({ - where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } }, - select: { id: true }, - }) - ).map((r) => r.id), - ); - - const out: RecentEntryDto[] = []; - for (const row of rows) { - if (out.length >= RECENT_LIMIT) break; - const node = byId.get(row.nodeId); - if (node === undefined || node.deletedAt !== null) continue; - const hidden = node.pathIds - .split("/") - .filter((s) => s !== "" && s !== node.id) - .some((s) => deletedAncestorIds.has(s)); - if (hidden) continue; - out.push({ - nodeId: node.id, - kind: node.kind, - name: node.name, - filePath: row.filePath, - openedAt: row.openedAt, - }); - } - return out; -} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 11b1ccb..79a88fe 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -29,7 +29,6 @@ import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerBinRoutes } from "./binRoutes.js"; -import { registerRecentRoutes } from "./recentRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; @@ -164,7 +163,6 @@ export async function registerDatabaseRoutes( await registerFileRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps); await registerBinRoutes(app, filelibDeps); - await registerRecentRoutes(app, filelibDeps); await registerTeacherApp(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/database/routes/recentRoutes.ts b/hub/src/database/routes/recentRoutes.ts deleted file mode 100644 index ebd6479..0000000 --- a/hub/src/database/routes/recentRoutes.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * /database/api/recent 最近打开端点(ADR-0031)。 - * 约定:绝对路径;actorOrNull 前置;业务全走 recentService;错误统一 sendRouteError。 - */ - -import type { FastifyInstance } from "fastify"; -import { listRecent, recordVisit } from "../filelib/recentService.js"; -import { FileLibError } from "../filelib/model.js"; -import { - actorOrNull, - bodyObject, - optionalString, - requireString, - sendRouteError, - type FileLibRouteDeps, -} from "../filelib/routeShared.js"; - -export async function registerRecentRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise { - const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver }; - - app.get("/database/api/recent", async (request, reply) => { - const actor = await actorOrNull(request, reply, deps); - if (actor === null) return reply; - try { - return { entries: await listRecent(svc, actor) }; - } catch (error) { - return sendRouteError(reply, error); - } - }); - - app.post("/database/api/recent", async (request, reply) => { - const actor = await actorOrNull(request, reply, deps); - if (actor === null) return reply; - try { - const body = bodyObject(request.body); - const nodeId = requireString(body, "nodeId"); - const filePath = optionalString(body, "filePath"); - if (filePath !== undefined && filePath.trim() === "") { - throw new FileLibError(400, "invalid_request", "filePath must be non-empty when present"); - } - await recordVisit(svc, actor, nodeId, filePath); - return reply.status(204).send(); - } catch (error) { - return sendRouteError(reply, error); - } - }); -} diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index 926da4e..c28d434 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -1,8 +1,7 @@ /** - * 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。 + * 回收站集成测试(真实 Postgres,ADR-0031;最近打开已由 ADR-0032 移除)。 * 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义 - * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 / - * upsert 刷新 / 删除与祖先删除的可见性过滤。 + * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)。 * 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。 */ import { beforeEach, describe, expect, it } from "vitest"; @@ -11,12 +10,10 @@ import { createNode, softDeleteNode, listChildren, - getEffectiveRole, type FileLibActor, type TreeServiceDeps, } from "../../src/database/filelib/treeService.js"; import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js"; -import { listRecent, recordVisit, type RecentDeps } from "../../src/database/filelib/recentService.js"; import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js"; import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js"; @@ -43,14 +40,6 @@ function binDeps(): BinDeps { }; } -function recentDeps(): RecentDeps { - return { - prisma, - organizationId: DEFAULT_ORG_ID, - groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), - }; -} - beforeEach(async () => { await resetDb(); for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) { @@ -124,40 +113,3 @@ describe("binService · 彻底删除", () => { expect(audits).toHaveLength(1); }); }); - -describe("recentService · 最近打开", () => { - it("上报需 VIEW(404);upsert 刷新 openedAt;删除/祖先删除的条目被过滤", async () => { - const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); - const proj = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141" }); - const secret = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "秘密" }); - - // bob 对 secret 无 VIEW → 404 - await expect(recordVisit(recentDeps(), BOB, secret.id, undefined)).rejects.toMatchObject({ statusCode: 404 }); - - // admin 上报:root、proj、proj 内文件 - await recordVisit(recentDeps(), ADMIN, root.id, undefined); - await recordVisit(recentDeps(), ADMIN, proj.id, undefined); - await recordVisit(recentDeps(), ADMIN, proj.id, "讲义/第一章.md"); - - let entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toHaveLength(3); - expect(entries.map((e) => e.filePath)).toContain("讲义/第一章.md"); - - // upsert:重复打开 root 只刷新,不新增 - await recordVisit(recentDeps(), ADMIN, root.id, undefined); - entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toHaveLength(3); - expect(entries[0]!.nodeId).toBe(root.id); // 最新在前 - - // 删祖先 → 整支条目消失 - await softDeleteNode(treeDeps(), ADMIN, root.id); - entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toEqual([]); - - // 恢复后重新可见 - await restoreBinEntry(binDeps(), ADMIN, root.id); - entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toHaveLength(3); - expect(await getEffectiveRole(treeDeps(), ADMIN, proj.id)).toBe("MANAGE"); - }); -});