forked from bai/curriculum-project-hub
feat(filelib): 老师端左栏导航:回收站 + 最近打开(ADR-0031)
- 回收站: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 例绿
This commit is contained in:
@@ -19,6 +19,10 @@ export const FILE_LIB_AUDIT_ACTIONS = {
|
||||
projectRename: "project.rename",
|
||||
projectMove: "project.move",
|
||||
projectDelete: "project.delete",
|
||||
// ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
|
||||
folderRestore: "folder.restore",
|
||||
projectRestore: "project.restore",
|
||||
nodePurge: "node.purge",
|
||||
permissionGrant: "permission.grant",
|
||||
permissionUpdate: "permission.update",
|
||||
permissionRevoke: "permission.revoke",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 回收站(ADR-0031)。
|
||||
*
|
||||
* 列出:deletedAt != null 且**祖先全活跃**的节点(每支已删子树只露顶)。
|
||||
* 可见性:网站管理员,或在该已删节点上持活跃 MANAGE grant(直连 grant,
|
||||
* 不走继承 —— 回收站是管理面,不是浏览面)。
|
||||
* 恢复:只清本节点 deletedAt(与 D15 删除对称),整支立即可见,落审计。
|
||||
* 彻底删除:仅网站管理员;按 pathIds 物化路径枚举子树,**自最深一层逐批
|
||||
* 向上删**(self-FK 是 ON DELETE RESTRICT,一次 deleteMany 不保证顺序),
|
||||
* 同事务一条 node.purge 审计。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { FileLibError } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
import type { FileLibActor } from "./treeService.js";
|
||||
|
||||
export interface BinDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly organizationId: string;
|
||||
readonly groupResolver: GroupResolver;
|
||||
}
|
||||
|
||||
export interface BinEntryDto {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
readonly name: string;
|
||||
readonly deletedAt: Date;
|
||||
}
|
||||
|
||||
/** actor 对 node 是否可见(管理员,或节点上的直连 MANAGE —— USER 或其已解析组)。 */
|
||||
async function canSeeEntry(
|
||||
tx: Pick<PrismaClient, "fileLibGrant">,
|
||||
deps: BinDeps,
|
||||
actor: FileLibActor,
|
||||
groupIds: readonly string[],
|
||||
nodeId: string,
|
||||
): Promise<boolean> {
|
||||
if (actor.isWebsiteAdmin) return true;
|
||||
const grant = await tx.fileLibGrant.findFirst({
|
||||
where: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId,
|
||||
revokedAt: null,
|
||||
role: "MANAGE",
|
||||
OR: [
|
||||
{ principalType: "USER", principalId: actor.userId },
|
||||
...(groupIds.length > 0
|
||||
? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return grant !== null;
|
||||
}
|
||||
|
||||
/** 列出回收站(祖先全活跃的已删节点顶)。 */
|
||||
export async function listBin(deps: BinDeps, actor: FileLibActor): Promise<readonly BinEntryDto[]> {
|
||||
const deleted = await deps.prisma.fileLibNode.findMany({
|
||||
where: { organizationId: deps.organizationId, deletedAt: { not: null } },
|
||||
orderBy: { deletedAt: "desc" },
|
||||
});
|
||||
if (deleted.length === 0) return [];
|
||||
|
||||
// 祖先活跃性:收集所有 pathIds 里的祖先段,查哪些已删,做集合判定。
|
||||
const ancestorIds = new Set<string>();
|
||||
for (const n of deleted) {
|
||||
const segments = n.pathIds.split("/").filter((s) => s !== "" && s !== n.id);
|
||||
for (const s of segments) ancestorIds.add(s);
|
||||
}
|
||||
const deletedAncestorIds = new Set(
|
||||
(
|
||||
await deps.prisma.fileLibNode.findMany({
|
||||
where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((r) => r.id),
|
||||
);
|
||||
const tops = deleted.filter(
|
||||
(n) => !n.pathIds.split("/").filter((s) => s !== "" && s !== n.id).some((s) => deletedAncestorIds.has(s)),
|
||||
);
|
||||
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
const out: BinEntryDto[] = [];
|
||||
for (const n of tops) {
|
||||
if (await canSeeEntry(deps.prisma, deps, actor, groupIds, n.id)) {
|
||||
out.push({ id: n.id, parentId: n.parentId, kind: n.kind, name: n.name, deletedAt: n.deletedAt! });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 取回收站条目并做可见性门禁(D8:不可见即 404)。 */
|
||||
async function requireBinEntry(
|
||||
tx: PrismaClient,
|
||||
deps: BinDeps,
|
||||
actor: FileLibActor,
|
||||
groupIds: readonly string[],
|
||||
nodeId: string,
|
||||
): Promise<{ readonly id: string; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> {
|
||||
const node = await tx.fileLibNode.findFirst({
|
||||
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
|
||||
});
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) {
|
||||
throw new FileLibError(404, "node_not_found", "node not found");
|
||||
}
|
||||
return { id: node.id, kind: node.kind, name: node.name, pathIds: node.pathIds };
|
||||
}
|
||||
|
||||
/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */
|
||||
export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<void> {
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
|
||||
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } });
|
||||
await writeFileLibAudit(tx, {
|
||||
action: node.kind === "PROJECT"
|
||||
? FILE_LIB_AUDIT_ACTIONS.projectRestore
|
||||
: FILE_LIB_AUDIT_ACTIONS.folderRestore,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举,
|
||||
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
|
||||
* 父行必须晚于全部子孙行删除。
|
||||
*/
|
||||
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(404, "node_not_found", "node not found");
|
||||
}
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const node = await tx.fileLibNode.findFirst({
|
||||
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
|
||||
});
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
|
||||
const subtree = await tx.fileLibNode.findMany({
|
||||
where: {
|
||||
organizationId: deps.organizationId,
|
||||
OR: [{ id: node.id }, { pathIds: { startsWith: `${node.pathIds}/` } }],
|
||||
},
|
||||
select: { id: true, pathIds: true },
|
||||
});
|
||||
const depthOf = (p: string): number => p.split("/").filter((s) => s !== "").length;
|
||||
const byDepthDesc = [...subtree].sort((a, b) => depthOf(b.pathIds) - depthOf(a.pathIds));
|
||||
let removed = 0;
|
||||
let cursor = 0;
|
||||
while (cursor < byDepthDesc.length) {
|
||||
const depth = depthOf(byDepthDesc[cursor]!.pathIds);
|
||||
const batch: string[] = [];
|
||||
while (cursor < byDepthDesc.length && depthOf(byDepthDesc[cursor]!.pathIds) === depth) {
|
||||
batch.push(byDepthDesc[cursor]!.id);
|
||||
cursor += 1;
|
||||
}
|
||||
removed += (await tx.fileLibNode.deleteMany({ where: { id: { in: batch } } })).count;
|
||||
}
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name, removed },
|
||||
});
|
||||
return { removed };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 最近打开(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;
|
||||
}
|
||||
@@ -443,10 +443,12 @@ export async function getEffectiveRole(
|
||||
|
||||
export interface BreadcrumbEntry {
|
||||
readonly depth: number;
|
||||
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
|
||||
/** D17:无 View 的祖先 id/name 置为 null(不泄露)。 */
|
||||
readonly id: string | null;
|
||||
readonly name: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
/** 该节点对调用者的 effective role;无 View 为 null。 */
|
||||
readonly role: FileLibRole | null;
|
||||
}
|
||||
|
||||
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
|
||||
@@ -484,6 +486,7 @@ export async function breadcrumb(
|
||||
id: visible ? current.id : null,
|
||||
name: visible ? current.name : null,
|
||||
kind: current.kind,
|
||||
role,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user