forked from bai/curriculum-project-hub
Merge branch 'maoyuanyang-main'
# Conflicts: # hub/filelib-web/src/lib/GrantsPanel.svelte # hub/filelib-web/src/lib/OverviewPanel.svelte # hub/filelib-web/src/lib/types.ts # hub/src/database/filelib/grantService.ts
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,201 @@
|
||||
/**
|
||||
* 回收站(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 };
|
||||
});
|
||||
}
|
||||
@@ -25,13 +25,10 @@ export interface GrantDto {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
/**
|
||||
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
|
||||
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
|
||||
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
|
||||
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
|
||||
*/
|
||||
readonly principalName: string;
|
||||
/** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */
|
||||
readonly principalName: string | null;
|
||||
/** USER 主体的飞书 openId;GROUP 或主体已删时为 null。 */
|
||||
readonly principalOpenId: string | null;
|
||||
readonly role: FileLibRole;
|
||||
readonly isCreatorGrant: boolean;
|
||||
readonly createdAt: Date;
|
||||
@@ -42,13 +39,42 @@ function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
|
||||
id: grant.id,
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
principalName: principalName ?? grant.principalId,
|
||||
principalName: null,
|
||||
principalOpenId: null,
|
||||
role: grant.role,
|
||||
isCreatorGrant: grant.isCreatorGrant,
|
||||
createdAt: grant.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 批量回填主体显示名与飞书 openId(两次查询,不做 per-row 往返)。可在事务内调用。 */
|
||||
async function withPrincipalNames(
|
||||
prisma: Pick<PrismaClient, "user" | "memberGroup">,
|
||||
grants: readonly GrantDto[],
|
||||
): Promise<readonly GrantDto[]> {
|
||||
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
|
||||
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
|
||||
const users = userIds.length === 0
|
||||
? []
|
||||
: await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, displayName: true, feishuOpenId: true },
|
||||
});
|
||||
const groups = groupIds.length === 0
|
||||
? []
|
||||
: await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } });
|
||||
const nameById = new Map<string, string>([
|
||||
...users.map((u) => [u.id, u.displayName] as const),
|
||||
...groups.map((g) => [g.id, g.name] as const),
|
||||
]);
|
||||
const openIdById = new Map<string, string>(users.map((u) => [u.id, u.feishuOpenId] as const));
|
||||
return grants.map((g) => ({
|
||||
...g,
|
||||
principalName: nameById.get(g.principalId) ?? null,
|
||||
principalOpenId: g.principalType === "USER" ? openIdById.get(g.principalId) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
type Deps = AccessDeps & { readonly prisma: PrismaClient };
|
||||
|
||||
@@ -103,7 +129,7 @@ export async function listGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return toDtosWithNames(deps.prisma, grants);
|
||||
return withPrincipalNames(deps.prisma, grants.map(toDto));
|
||||
}
|
||||
|
||||
export interface PutGrantsResult {
|
||||
@@ -174,7 +200,7 @@ export async function putGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -273,46 +299,7 @@ export async function forceAdjustGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
|
||||
export async function setIndependentPermission(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ readonly enabled: boolean }> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireManage(deps, actor, nodeId, tx);
|
||||
if (node.kind !== "PROJECT") {
|
||||
throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
|
||||
}
|
||||
const current = await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
});
|
||||
if ((current?.independentPermissionsEnabled ?? false) === enabled) {
|
||||
return { enabled }; // 状态未变:空操作,不产生审计
|
||||
}
|
||||
await tx.fileLibProjectSettings.upsert({
|
||||
where: { nodeId: node.id },
|
||||
update: { independentPermissionsEnabled: enabled },
|
||||
create: { nodeId: node.id, independentPermissionsEnabled: enabled },
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: enabled
|
||||
? FILE_LIB_AUDIT_ACTIONS.independentEnable
|
||||
: FILE_LIB_AUDIT_ACTIONS.independentDisable,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "project",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { enabled },
|
||||
});
|
||||
return { enabled };
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 纯权限 reducer(契约 P6 / D11 / D8)。
|
||||
* 纯权限 reducer(契约 P6 / D8)。
|
||||
*
|
||||
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
|
||||
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
|
||||
@@ -23,8 +23,6 @@ export interface EffectiveRoleInput {
|
||||
readonly nodeKind: "FOLDER" | "PROJECT";
|
||||
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
|
||||
readonly ancestorIds: readonly string[];
|
||||
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
|
||||
readonly independentPermissionsEnabled: boolean;
|
||||
readonly userId: string;
|
||||
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
|
||||
readonly groupIds: readonly string[];
|
||||
@@ -37,20 +35,16 @@ export interface EffectiveRoleInput {
|
||||
* r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。
|
||||
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
|
||||
*
|
||||
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
|
||||
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
|
||||
* grant 不受开关影响。
|
||||
* 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除,
|
||||
* FileLibProjectSettings 不再被读取。
|
||||
*/
|
||||
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
|
||||
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
|
||||
const groups = new Set(input.groupIds);
|
||||
const freezeProjectGrants =
|
||||
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
|
||||
|
||||
let best: FileLibRole | null = null;
|
||||
for (const grant of input.grants) {
|
||||
if (!onChain.has(grant.nodeId)) continue;
|
||||
if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
|
||||
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
|
||||
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
|
||||
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
|
||||
|
||||
@@ -99,7 +99,7 @@ async function loadVisibleChain(
|
||||
return { node, ancestors: ordered };
|
||||
}
|
||||
|
||||
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
|
||||
/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */
|
||||
async function resolveRole(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
@@ -111,20 +111,11 @@ async function resolveRole(
|
||||
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,
|
||||
@@ -285,11 +276,6 @@ export async function createNode(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.kind === "PROJECT") {
|
||||
await tx.fileLibProjectSettings.create({
|
||||
data: { nodeId: id, independentPermissionsEnabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(input.kind, "Create"),
|
||||
@@ -465,10 +451,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 只留占位。 */
|
||||
@@ -490,20 +478,12 @@ export async function breadcrumb(
|
||||
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,
|
||||
@@ -514,6 +494,7 @@ export async function breadcrumb(
|
||||
id: visible ? current.id : null,
|
||||
name: visible ? current.name : null,
|
||||
kind: current.kind,
|
||||
role,
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -553,14 +534,6 @@ export async function listChildren(
|
||||
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[] = [];
|
||||
@@ -569,7 +542,6 @@ export async function listChildren(
|
||||
nodeId: child.id,
|
||||
nodeKind: child.kind,
|
||||
ancestorIds: parentAncestorIds,
|
||||
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* /database/api/bin/* 回收站端点(ADR-0031)。
|
||||
* 约定:绝对路径;actorOrNull 前置;业务全走 binService;错误统一 sendRouteError。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { listBin, purgeBinEntry, restoreBinEntry } from "../filelib/binService.js";
|
||||
import { actorOrNull, sendRouteError, type FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise<void> {
|
||||
const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
|
||||
|
||||
app.get("/database/api/bin", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
return { entries: await listBin(svc, actor) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/bin/:id/restore", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return await restoreBinEntry(svc, actor, id);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/bin/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return await purgeBinEntry(svc, actor, id);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||
import { registerFileRoutes } from "./fileRoutes.js";
|
||||
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
||||
import { registerBinRoutes } from "./binRoutes.js";
|
||||
import { registerTeacherApp } from "./teacherApp.js";
|
||||
import { createGitVersionStore } from "../filelib/gitVersionStore.js";
|
||||
import { resolveMaxFileBytes } from "../filelib/fileService.js";
|
||||
@@ -166,6 +167,7 @@ export async function registerDatabaseRoutes(
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
await registerFileRoutes(app, filelibDeps);
|
||||
await registerMemberGroupRoutes(app, filelibDeps);
|
||||
await registerBinRoutes(app, filelibDeps);
|
||||
await registerTeacherApp(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
listGrants,
|
||||
putGrants,
|
||||
revokeGrant,
|
||||
setIndependentPermission,
|
||||
} from "../filelib/grantService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
@@ -114,9 +113,6 @@ export async function registerFileLibRoutes(
|
||||
where: { id, organizationId: deps.organizationId },
|
||||
});
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
const settings = node.kind === "PROJECT"
|
||||
? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } })
|
||||
: null;
|
||||
return {
|
||||
node: {
|
||||
id: node.id,
|
||||
@@ -126,7 +122,6 @@ export async function registerFileLibRoutes(
|
||||
description: node.description,
|
||||
role,
|
||||
provisionStatus: node.provisionStatus,
|
||||
independentPermission: settings?.independentPermissionsEnabled ?? false,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
},
|
||||
@@ -258,21 +253,6 @@ export async function registerFileLibRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/database/api/projects/:id/independent-permission", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
if (typeof body["enabled"] !== "boolean") {
|
||||
throw new FileLibError(400, "invalid_request", "enabled must be a boolean");
|
||||
}
|
||||
return await setIndependentPermission(grantDeps, actor, id, body["enabled"]);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
|
||||
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user