forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。
|
||||
*
|
||||
* 语义锚定:
|
||||
* - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess)
|
||||
* - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts
|
||||
* - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环
|
||||
* - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底)
|
||||
* - D15 删除只打标本节点;"任一祖先已删"即整支不可见
|
||||
* - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字
|
||||
* - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径,
|
||||
* rename 不重写后代;move 用一次前缀重写维护)
|
||||
*
|
||||
* 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建
|
||||
* 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走
|
||||
* effectiveRole,没有 admin 旁路。
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClient, FileLibNode } from "@prisma/client";
|
||||
import {
|
||||
FileLibError,
|
||||
nameKey,
|
||||
normalizeNodeName,
|
||||
type FileLibRole,
|
||||
} from "./model.js";
|
||||
import { checkAccess, effectiveRole } from "./permission.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
import type { VersionStore } from "./versionStore.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
|
||||
export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly groupResolver: GroupResolver;
|
||||
readonly versionStore: VersionStore;
|
||||
/** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */
|
||||
readonly organizationId: string;
|
||||
/** 项目 git 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
|
||||
readonly storageRoot: string;
|
||||
}
|
||||
|
||||
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
|
||||
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
|
||||
|
||||
export interface InitialGrant {
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: FileLibRole;
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
interface Chain {
|
||||
readonly node: FileLibNode;
|
||||
/** 根在前、直接父在后;不含 node 自身。 */
|
||||
readonly ancestors: readonly FileLibNode[];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部工具 */
|
||||
|
||||
/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */
|
||||
function ancestorIdsOf(node: FileLibNode): string[] {
|
||||
return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1);
|
||||
}
|
||||
|
||||
/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */
|
||||
async function loadVisibleChain(
|
||||
tx: Tx,
|
||||
organizationId: string,
|
||||
nodeId: string,
|
||||
): Promise<Chain> {
|
||||
const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } });
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
const ancestorIds = ancestorIdsOf(node);
|
||||
const ancestors = ancestorIds.length === 0
|
||||
? []
|
||||
: await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } });
|
||||
if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) {
|
||||
// D15:已删子树对外"不存在"(D8 不泄露)。
|
||||
throw new FileLibError(404, "node_not_found", "node not found");
|
||||
}
|
||||
const byId = new Map(ancestors.map((a) => [a.id, a]));
|
||||
const ordered = ancestorIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((a): a is FileLibNode => a !== undefined);
|
||||
return { node, ancestors: ordered };
|
||||
}
|
||||
|
||||
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
|
||||
async function resolveRole(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
actor: FileLibActor,
|
||||
chain: Chain,
|
||||
): Promise<FileLibRole | null> {
|
||||
const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id];
|
||||
const grants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
let independentPermissionsEnabled = false;
|
||||
if (chain.node.kind === "PROJECT") {
|
||||
const settings = await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: chain.node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
});
|
||||
independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
|
||||
}
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
return effectiveRole({
|
||||
nodeId: chain.node.id,
|
||||
nodeKind: chain.node.kind,
|
||||
ancestorIds: chain.ancestors.map((a) => a.id),
|
||||
independentPermissionsEnabled,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants,
|
||||
});
|
||||
}
|
||||
|
||||
/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */
|
||||
async function requireAccess(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
minRole: FileLibRole,
|
||||
): Promise<Chain & { readonly role: FileLibRole }> {
|
||||
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
||||
const role = await resolveRole(tx, deps, actor, chain);
|
||||
const verdict = checkAccess(role, minRole);
|
||||
if (!verdict.allowed) {
|
||||
throw verdict.reason === "not_found"
|
||||
? new FileLibError(404, "node_not_found", "node not found")
|
||||
: new FileLibError(403, "forbidden", `requires ${minRole}`);
|
||||
}
|
||||
return { ...chain, role: verdict.role };
|
||||
}
|
||||
|
||||
/**
|
||||
* 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做
|
||||
* 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。
|
||||
*/
|
||||
export async function requireAccessInTx(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
minRole: FileLibRole,
|
||||
): Promise<Chain & { readonly role: FileLibRole }> {
|
||||
return requireAccess(tx, deps, actor, nodeId, minRole);
|
||||
}
|
||||
|
||||
/** P2002(活跃兄弟名部分唯一索引)→ 409。 */
|
||||
function rethrowNameConflict(error: unknown, name: string): never {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string {
|
||||
const table = kind === "PROJECT"
|
||||
? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete }
|
||||
: { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete };
|
||||
return table[verb];
|
||||
}
|
||||
|
||||
function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const grant of grants) {
|
||||
const key = `${grant.principalType}:${grant.principalId}`;
|
||||
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`);
|
||||
seen.add(key);
|
||||
if (grant.principalType === "USER" && grant.principalId === actor.userId) {
|
||||
throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant");
|
||||
}
|
||||
// v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 公共操作 */
|
||||
|
||||
export interface CreateNodeInput {
|
||||
readonly parentId: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
readonly name: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly grants?: readonly InitialGrant[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。
|
||||
* creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。
|
||||
*/
|
||||
export async function createNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
input: CreateNodeInput,
|
||||
): Promise<FileLibNode> {
|
||||
const name = normalizeNodeName(input.name);
|
||||
const initialGrants = input.grants ?? [];
|
||||
validateInitialGrants(actor, initialGrants);
|
||||
|
||||
const id = randomUUID();
|
||||
let pathIds: string;
|
||||
let storageDir: string | null = null;
|
||||
|
||||
const created = await deps.prisma.$transaction(async (tx) => {
|
||||
if (input.parentId === null) {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "root creation requires website administrator");
|
||||
}
|
||||
pathIds = `/${id}`;
|
||||
} else {
|
||||
const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT");
|
||||
if (parent.node.kind !== "FOLDER") {
|
||||
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
|
||||
}
|
||||
pathIds = `${parent.node.pathIds}/${id}`;
|
||||
}
|
||||
|
||||
if (input.kind === "PROJECT") {
|
||||
storageDir = path.join(deps.storageRoot, id);
|
||||
}
|
||||
|
||||
let node: FileLibNode;
|
||||
try {
|
||||
node = await tx.fileLibNode.create({
|
||||
data: {
|
||||
id,
|
||||
organizationId: deps.organizationId,
|
||||
parentId: input.parentId,
|
||||
kind: input.kind,
|
||||
name,
|
||||
nameLower: nameKey(name),
|
||||
pathIds,
|
||||
creatorId: actor.userId,
|
||||
provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY",
|
||||
storageDir,
|
||||
...(input.description !== undefined && input.description.trim() !== ""
|
||||
? { description: input.description.trim() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNameConflict(error, name);
|
||||
}
|
||||
|
||||
await tx.fileLibGrant.create({
|
||||
data: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: id,
|
||||
principalType: "USER",
|
||||
principalId: actor.userId,
|
||||
role: "MANAGE",
|
||||
isCreatorGrant: true,
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
for (const grant of initialGrants) {
|
||||
await tx.fileLibGrant.create({
|
||||
data: {
|
||||
organizationId: deps.organizationId,
|
||||
nodeId: id,
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
role: grant.role,
|
||||
createdByUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.kind === "PROJECT") {
|
||||
await tx.fileLibProjectSettings.create({
|
||||
data: { nodeId: id, independentPermissionsEnabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(input.kind, "Create"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: input.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: id,
|
||||
objectPath: pathIds,
|
||||
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
|
||||
});
|
||||
for (const grant of initialGrants) {
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "grant",
|
||||
objectId: id,
|
||||
objectPath: pathIds,
|
||||
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
||||
});
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
|
||||
if (input.kind === "PROJECT" && storageDir !== null) {
|
||||
try {
|
||||
await deps.versionStore.init(storageDir);
|
||||
return await deps.prisma.fileLibNode.update({
|
||||
where: { id: created.id },
|
||||
data: { provisionStatus: "READY" },
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.prisma.fileLibNode
|
||||
.update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } })
|
||||
.catch(() => undefined);
|
||||
throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */
|
||||
export async function renameNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
rawName: string,
|
||||
): Promise<FileLibNode> {
|
||||
const name = normalizeNodeName(rawName);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
let updated: FileLibNode;
|
||||
try {
|
||||
updated = await tx.fileLibNode.update({
|
||||
where: { id: node.id },
|
||||
data: { name, nameLower: nameKey(name) },
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNameConflict(error, name);
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Rename"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { from: node.name, to: name },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员);
|
||||
* 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。
|
||||
*/
|
||||
export async function moveNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
newParentId: string | null,
|
||||
): Promise<FileLibNode> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`;
|
||||
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
if (node.parentId === newParentId) return node;
|
||||
|
||||
let newPathIds: string;
|
||||
if (newParentId === null) {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "moving to root requires website administrator");
|
||||
}
|
||||
newPathIds = `/${node.id}`;
|
||||
} else {
|
||||
const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT");
|
||||
if (parent.node.kind !== "FOLDER") {
|
||||
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
|
||||
}
|
||||
if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) {
|
||||
throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree");
|
||||
}
|
||||
newPathIds = `${parent.node.pathIds}/${node.id}`;
|
||||
}
|
||||
|
||||
const oldPrefix = node.pathIds;
|
||||
let updated: FileLibNode;
|
||||
try {
|
||||
updated = await tx.fileLibNode.update({
|
||||
where: { id: node.id },
|
||||
data: { parentId: newParentId, pathIds: newPathIds },
|
||||
});
|
||||
// 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。
|
||||
await tx.$executeRaw`
|
||||
UPDATE "FileLibNode"
|
||||
SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int)
|
||||
WHERE "organizationId" = ${deps.organizationId}
|
||||
AND "pathIds" LIKE ${oldPrefix + "/%"}
|
||||
`;
|
||||
} catch (error) {
|
||||
rethrowNameConflict(error, node.name);
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Move"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: newPathIds,
|
||||
detail: { fromParentId: node.parentId, toParentId: newParentId },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */
|
||||
export async function softDeleteNode(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<void> {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Delete"),
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */
|
||||
export async function getEffectiveRole(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<FileLibRole | null> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
||||
const role = await resolveRole(tx, deps, actor, chain);
|
||||
if (role === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
return role;
|
||||
});
|
||||
}
|
||||
|
||||
export interface BreadcrumbEntry {
|
||||
readonly depth: number;
|
||||
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
|
||||
readonly id: string | null;
|
||||
readonly name: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
}
|
||||
|
||||
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
|
||||
export async function breadcrumb(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
): Promise<readonly BreadcrumbEntry[]> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
||||
const selfRole = await resolveRole(tx, deps, actor, chain);
|
||||
if (checkAccess(selfRole, "VIEW").allowed !== true) {
|
||||
throw new FileLibError(404, "node_not_found", "node not found");
|
||||
}
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
const chainNodes = [...chain.ancestors, chain.node];
|
||||
const chainIds = chainNodes.map((n) => n.id);
|
||||
const allGrants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
const settings = chain.node.kind === "PROJECT"
|
||||
? await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: chain.node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return chainNodes.map((current, depth) => {
|
||||
const role = effectiveRole({
|
||||
nodeId: current.id,
|
||||
nodeKind: current.kind,
|
||||
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
|
||||
independentPermissionsEnabled:
|
||||
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
});
|
||||
const visible = role !== null;
|
||||
return {
|
||||
depth,
|
||||
id: visible ? current.id : null,
|
||||
name: visible ? current.name : null,
|
||||
kind: current.kind,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface ChildNodeDto {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
readonly name: string;
|
||||
readonly role: FileLibRole;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */
|
||||
export async function listChildren(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
parentId: string | null,
|
||||
): Promise<readonly ChildNodeDto[]> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
let parentAncestorIds: string[] = [];
|
||||
if (parentId !== null) {
|
||||
const parent = await requireAccess(tx, deps, actor, parentId, "VIEW");
|
||||
parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id];
|
||||
}
|
||||
const children = await tx.fileLibNode.findMany({
|
||||
where: { organizationId: deps.organizationId, parentId, deletedAt: null },
|
||||
orderBy: [{ kind: "asc" }, { nameLower: "asc" }],
|
||||
});
|
||||
if (children.length === 0) return [];
|
||||
|
||||
// D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。
|
||||
const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)];
|
||||
const allGrants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
|
||||
const settingsRows = projectIds.length === 0
|
||||
? []
|
||||
: await tx.fileLibProjectSettings.findMany({
|
||||
where: { nodeId: { in: projectIds } },
|
||||
select: { nodeId: true, independentPermissionsEnabled: true },
|
||||
});
|
||||
const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
|
||||
const out: ChildNodeDto[] = [];
|
||||
for (const child of children) {
|
||||
const role = effectiveRole({
|
||||
nodeId: child.id,
|
||||
nodeKind: child.kind,
|
||||
ancestorIds: parentAncestorIds,
|
||||
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
});
|
||||
if (role === null) continue;
|
||||
out.push({
|
||||
id: child.id,
|
||||
parentId: child.parentId,
|
||||
kind: child.kind,
|
||||
name: child.name,
|
||||
role,
|
||||
createdAt: child.createdAt,
|
||||
updatedAt: child.updatedAt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */
|
||||
export async function updateNodeDescription(
|
||||
deps: TreeServiceDeps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
description: string | null,
|
||||
): Promise<FileLibNode> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT");
|
||||
return tx.fileLibNode.update({
|
||||
where: { id: node.id },
|
||||
data: { description: description?.trim() || null },
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user