forked from bai/curriculum-project-hub
Compare commits
2 Commits
475b397ca8
...
f99c8ea4d8
| Author | SHA1 | Date | |
|---|---|---|---|
| f99c8ea4d8 | |||
| 83a6b012b7 |
@@ -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.
|
||||||
@@ -23,14 +23,6 @@
|
|||||||
import GrantsPanel from "./GrantsPanel.svelte";
|
import GrantsPanel from "./GrantsPanel.svelte";
|
||||||
import OverviewPanel from "./OverviewPanel.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<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
|
const RANK: Record<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
|
||||||
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
|
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
|
||||||
|
|
||||||
@@ -107,14 +99,6 @@
|
|||||||
|
|
||||||
onMount(loadChildren);
|
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 {
|
function refresh(): void {
|
||||||
selected = null;
|
selected = null;
|
||||||
menu = null;
|
menu = null;
|
||||||
@@ -126,7 +110,6 @@
|
|||||||
|
|
||||||
function openNode(n: StackItem): void {
|
function openNode(n: StackItem): void {
|
||||||
selected = null;
|
selected = null;
|
||||||
record(n.id);
|
|
||||||
if (n.kind === "FOLDER") {
|
if (n.kind === "FOLDER") {
|
||||||
stack = [...stack, n];
|
stack = [...stack, n];
|
||||||
void loadChildren();
|
void loadChildren();
|
||||||
@@ -174,43 +157,6 @@
|
|||||||
void loadChildren();
|
void loadChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */
|
|
||||||
async function navigateTo(target: NavTarget): Promise<void> {
|
|
||||||
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 {
|
function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void {
|
||||||
@@ -300,7 +246,6 @@
|
|||||||
/* ------------------------------------------------------------ 文件操作 */
|
/* ------------------------------------------------------------ 文件操作 */
|
||||||
|
|
||||||
function previewFile(f: FileEntry): void {
|
function previewFile(f: FileEntry): void {
|
||||||
if (projectNode !== null) record(projectNode.id, f.path);
|
|
||||||
selectedFilePath.set(f.path);
|
selectedFilePath.set(f.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* 最近打开(ADR-0031):本人最近 20 条,点击跳回文件库对应位置
|
|
||||||
* (经 onopen 把导航目标交给外层,由 GridLibraryView 建栈跳转)。
|
|
||||||
*/
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { api } from "./api.js";
|
|
||||||
import type { RecentEntry } from "./types.js";
|
|
||||||
import Icon from "./Icon.svelte";
|
|
||||||
|
|
||||||
let { onopen }: { onopen: (target: { nodeId: string; filePath?: string }) => void } = $props();
|
|
||||||
|
|
||||||
let entries = $state<RecentEntry[] | null>(null);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const r = await api<{ entries: RecentEntry[] }>("/database/api/recent");
|
|
||||||
entries = r.entries;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function fmt(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function iconOf(e: RecentEntry): "folder" | "layers" | "chevron" {
|
|
||||||
if (e.filePath !== "") return "chevron";
|
|
||||||
return e.kind === "FOLDER" ? "folder" : "layers";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
|
||||||
<h1 class="mb-4 text-[15px] font-semibold text-ink">最近打开</h1>
|
|
||||||
|
|
||||||
{#if error !== null}
|
|
||||||
<div class="py-8 text-center text-[13px] text-danger">{error}</div>
|
|
||||||
{:else if entries === null}
|
|
||||||
<div class="quiet py-8 text-center">加载中…</div>
|
|
||||||
{:else if entries.length === 0}
|
|
||||||
<div class="quiet py-8 text-center">还没有访问记录 · 去文件库逛逛</div>
|
|
||||||
{:else}
|
|
||||||
<div class="panel !p-2">
|
|
||||||
{#each entries as e (e.nodeId + "/" + e.filePath)}
|
|
||||||
<button
|
|
||||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition hover:bg-hover"
|
|
||||||
onclick={() => onopen({ nodeId: e.nodeId, ...(e.filePath !== "" ? { filePath: e.filePath } : {}) })}
|
|
||||||
>
|
|
||||||
<span class="flex text-ink-3"><Icon name={iconOf(e)} size={15} /></span>
|
|
||||||
<span class="min-w-0 flex-1">
|
|
||||||
<span class="block truncate text-[13px] text-ink">{e.name}</span>
|
|
||||||
{#if e.filePath !== ""}
|
|
||||||
<span class="block truncate font-mono text-[11px] text-ink-3">{e.filePath}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="quiet shrink-0">{fmt(e.openedAt)}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -132,16 +132,6 @@ export interface UserSearchResult {
|
|||||||
readonly avatarUrl: string | null;
|
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)。 */
|
/** 回收站条目(GET /database/api/bin)。 */
|
||||||
export interface BinEntry {
|
export interface BinEntry {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|||||||
@@ -4,25 +4,17 @@
|
|||||||
import { me, authChecked } from "$lib/stores.js";
|
import { me, authChecked } from "$lib/stores.js";
|
||||||
import { loadSession } from "$lib/session.js";
|
import { loadSession } from "$lib/session.js";
|
||||||
import LoginView from "$lib/LoginView.svelte";
|
import LoginView from "$lib/LoginView.svelte";
|
||||||
import GridLibraryView, { type NavTarget } from "$lib/GridLibraryView.svelte";
|
import GridLibraryView from "$lib/GridLibraryView.svelte";
|
||||||
import RecentView from "$lib/RecentView.svelte";
|
|
||||||
import BinView from "$lib/BinView.svelte";
|
import BinView from "$lib/BinView.svelte";
|
||||||
import Icon from "$lib/Icon.svelte";
|
import Icon from "$lib/Icon.svelte";
|
||||||
|
|
||||||
onMount(loadSession);
|
onMount(loadSession);
|
||||||
|
|
||||||
type View = "library" | "recent" | "bin";
|
type View = "library" | "bin";
|
||||||
let view = $state<View>("library");
|
let view = $state<View>("library");
|
||||||
let navTarget = $state<NavTarget | null>(null);
|
|
||||||
|
|
||||||
function openFromRecent(target: NavTarget): void {
|
const tabs: ReadonlyArray<readonly [View, string, "layers" | "trash"]> = [
|
||||||
navTarget = target;
|
|
||||||
view = "library";
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs: ReadonlyArray<readonly [View, string, "layers" | "clock" | "trash"]> = [
|
|
||||||
["library", "文件库", "layers"],
|
["library", "文件库", "layers"],
|
||||||
["recent", "最近打开", "clock"],
|
|
||||||
["bin", "回收站", "trash"],
|
["bin", "回收站", "trash"],
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
@@ -49,9 +41,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if view === "library"}
|
{#if view === "library"}
|
||||||
<GridLibraryView {navTarget} onnavigated={() => (navTarget = null)} />
|
<GridLibraryView />
|
||||||
{:else if view === "recent"}
|
|
||||||
<RecentView onopen={openFromRecent} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<BinView />
|
<BinView />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。
|
||||||
|
DROP TABLE "FileLibRecentVisit";
|
||||||
@@ -1110,18 +1110,3 @@ model FileLibExportJob {
|
|||||||
|
|
||||||
@@index([organizationId, status])
|
@@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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<void> {
|
|
||||||
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<readonly RecentEntryDto[]> {
|
|
||||||
// 可见性过滤会丢弃一部分,超取再截断。
|
|
||||||
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<string>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ import { registerFileLibRoutes } from "./filelibRoutes.js";
|
|||||||
import { registerFileRoutes } from "./fileRoutes.js";
|
import { registerFileRoutes } from "./fileRoutes.js";
|
||||||
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
||||||
import { registerBinRoutes } from "./binRoutes.js";
|
import { registerBinRoutes } from "./binRoutes.js";
|
||||||
import { registerRecentRoutes } from "./recentRoutes.js";
|
|
||||||
import { registerTeacherApp } from "./teacherApp.js";
|
import { registerTeacherApp } from "./teacherApp.js";
|
||||||
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
||||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||||
@@ -164,7 +163,6 @@ export async function registerDatabaseRoutes(
|
|||||||
await registerFileRoutes(app, filelibDeps);
|
await registerFileRoutes(app, filelibDeps);
|
||||||
await registerMemberGroupRoutes(app, filelibDeps);
|
await registerMemberGroupRoutes(app, filelibDeps);
|
||||||
await registerBinRoutes(app, filelibDeps);
|
await registerBinRoutes(app, filelibDeps);
|
||||||
await registerRecentRoutes(app, filelibDeps);
|
|
||||||
await registerTeacherApp(app, {
|
await registerTeacherApp(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
|
|||||||
@@ -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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。
|
* 回收站集成测试(真实 Postgres,ADR-0031;最近打开已由 ADR-0032 移除)。
|
||||||
* 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
|
* 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
|
||||||
* 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 /
|
* 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)。
|
||||||
* upsert 刷新 / 删除与祖先删除的可见性过滤。
|
|
||||||
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
|
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
|
||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
@@ -11,12 +10,10 @@ import {
|
|||||||
createNode,
|
createNode,
|
||||||
softDeleteNode,
|
softDeleteNode,
|
||||||
listChildren,
|
listChildren,
|
||||||
getEffectiveRole,
|
|
||||||
type FileLibActor,
|
type FileLibActor,
|
||||||
type TreeServiceDeps,
|
type TreeServiceDeps,
|
||||||
} from "../../src/database/filelib/treeService.js";
|
} from "../../src/database/filelib/treeService.js";
|
||||||
import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.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 { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
|
||||||
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||||
import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.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 () => {
|
beforeEach(async () => {
|
||||||
await resetDb();
|
await resetDb();
|
||||||
for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
|
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);
|
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user