forked from EduCraft/curriculum-project-hub
d9cde19bdf
恢复不再改名;同名兄弟占位时抛 409 name_conflict_on_restore + 人话提示, 操作者自行重命名现有节点或彻底删除旧节点后再恢复。
202 lines
7.6 KiB
TypeScript
202 lines
7.6 KiB
TypeScript
/**
|
|
* 回收站(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<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 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<RestoreResult> {
|
|
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 };
|
|
});
|
|
}
|