/** * 回收站(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, nameKey } 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, deps: BinDeps, actor: FileLibActor, groupIds: readonly string[], nodeId: string, ): Promise { 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 { 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(); 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 parentId: string | null; 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, parentId: node.parentId, kind: node.kind, name: node.name, pathIds: node.pathIds }; } export interface RestoreResult { readonly name: string; } /** * 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 * ADR-0033:与活跃兄弟撞名时不失败,自动改成「原名(已恢复[/ N])」—— * 恢复的意义就是找回,撞名死锁不是保护;审计 detail 记 renamedFrom。 */ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise { const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return deps.prisma.$transaction(async (tx) => { const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); const clash = await tx.fileLibNode.findFirst({ where: { organizationId: deps.organizationId, parentId: node.parentId, deletedAt: null, id: { not: node.id }, nameLower: nameKey(node.name), }, select: { id: true }, }); if (clash !== null) { throw new FileLibError(409, "name_conflict_on_restore", "name conflict on restore"); } 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 }, }); return { name: node.name }; }); } /** * 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE; * 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举, * 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT, * 父行必须晚于全部子孙行删除。 */ export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> { const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return deps.prisma.$transaction(async (tx) => { const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); 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 }; }); }