forked from EduCraft/curriculum-project-hub
feat(filelib): 移除最近打开模块(ADR-0032,supersede ADR-0031 对应半部)
- FileLibRecentVisit 删表(手写迁移;表当日新建无生产数据) - recentService/recentRoutes/RecentView 删除;GridLibraryView 埋点与 navTarget 跳转一并移除;types 清 RecentEntry - 左栏保留 文件库/回收站(ADR-0031 回收站半部不受影响) - breadcrumb 的 role 字段保留(独立可用的增量字段)
This commit is contained in:
@@ -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 { 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user