forked from EduCraft/curriculum-project-hub
d072e9ec1e
- 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、 restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举 子树按深度降序分批硬删,绕过 self-FK RESTRICT) - 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端 成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤 - 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView; GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带) - 测试:filelib-nav 集成 4 例;全套 79 例绿
107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
/**
|
|
* 最近打开(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;
|
|
}
|