forked from EduCraft/curriculum-project-hub
feat(database): 后台成员组(MemberGroup)管理与嵌套解析
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* /database/api/groups/* 成员组管理端点(ADR-0028)。
|
||||
* 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService;
|
||||
* 错误统一 sendRouteError。
|
||||
*
|
||||
* 管理端点(CRUD + 成员)由 service 层门禁到网站管理员;搜索端点不限管理员
|
||||
* (授权选择器是 Manage 持有者的能力,决策2)。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
addMember,
|
||||
createMemberGroup,
|
||||
deleteMemberGroup,
|
||||
listMemberGroups,
|
||||
listMembers,
|
||||
removeMember,
|
||||
restoreMemberGroup,
|
||||
searchMemberGroups,
|
||||
searchUsers,
|
||||
updateMemberGroup,
|
||||
} from "../filelib/memberGroupService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
actorOrNull,
|
||||
bodyObject,
|
||||
optionalString,
|
||||
requireString,
|
||||
sendRouteError,
|
||||
type FileLibRouteDeps,
|
||||
} from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerMemberGroupRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: FileLibRouteDeps,
|
||||
): Promise<void> {
|
||||
const svc = { prisma: deps.prisma, organizationId: deps.organizationId };
|
||||
|
||||
/* ------------------------------------------------------------ 搜索(授权选择器) */
|
||||
|
||||
// 契约 C2 /groups/search:活跃组 + breadcrumb。**非管理员可调**(决策2)。
|
||||
// 注:必须先于 "/database/api/groups" 之类的段前缀之外单独成路径,Fastify
|
||||
// 静态路由不会 shadow,顺序无关;此处与其它端点平级注册。
|
||||
app.get("/database/api/groups/search", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const q = (request.query as { q?: string }).q ?? "";
|
||||
return { groups: await searchMemberGroups(svc, q) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 成员选择器:搜全局用户。仅管理员(service 层门禁)。
|
||||
// excludeGroupId 过滤掉该组已有活跃成员。
|
||||
app.get("/database/api/users/search", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const query = request.query as { q?: string; excludeGroupId?: string };
|
||||
return { users: await searchUsers(svc, actor, query.q ?? "", query.excludeGroupId) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 组 CRUD */
|
||||
|
||||
// includeArchived=1 时连已归档组一并返回(带 archivedAt),供后台展示/恢复。
|
||||
app.get("/database/api/groups", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const raw = (request.query as { includeArchived?: string }).includeArchived;
|
||||
const includeArchived = raw === "1" || raw === "true";
|
||||
return { groups: await listMemberGroups(svc, actor, includeArchived) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/groups", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const body = bodyObject(request.body);
|
||||
const parentIdRaw = body["parentId"];
|
||||
if (parentIdRaw !== undefined && parentIdRaw !== null && typeof parentIdRaw !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
|
||||
}
|
||||
const group = await createMemberGroup(svc, actor, {
|
||||
name: requireString(body, "name"),
|
||||
description: optionalString(body, "description"),
|
||||
parentId: parentIdRaw === undefined ? null : parentIdRaw,
|
||||
});
|
||||
return reply.status(201).send({ group });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 改名 / 改描述(决策6)。不接受 parentId —— reparent 仍不在 v1(决策5)。
|
||||
app.patch("/database/api/groups/:id", 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 (body["parentId"] !== undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
|
||||
}
|
||||
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
|
||||
// (它把 "" 也归为 undefined)。
|
||||
const descRaw = body["description"];
|
||||
if (descRaw !== undefined && descRaw !== null && typeof descRaw !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "description must be a string");
|
||||
}
|
||||
const group = await updateMemberGroup(svc, actor, id, {
|
||||
name: optionalString(body, "name"),
|
||||
description: descRaw === undefined || descRaw === null ? undefined : descRaw,
|
||||
});
|
||||
return { group };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/groups/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const result = await deleteMemberGroup(svc, actor, id);
|
||||
return { archivedCount: result.archivedCount };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复(取消归档)。与删除不对称:只恢复该组 + 已归档祖先链,不动子树(决策7)。
|
||||
app.post("/database/api/groups/: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 };
|
||||
const result = await restoreMemberGroup(svc, actor, id);
|
||||
return { restoredCount: result.restoredCount };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 成员 */
|
||||
|
||||
app.get("/database/api/groups/:id/members", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return { members: await listMembers(svc, actor, id) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/groups/:id/members", 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);
|
||||
const userId = optionalString(body, "userId");
|
||||
const feishuOpenId = optionalString(body, "feishuOpenId");
|
||||
if (userId === undefined && feishuOpenId === undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
|
||||
}
|
||||
const member = await addMember(svc, actor, id, { userId, feishuOpenId });
|
||||
return reply.status(201).send({ member });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/groups/:id/members/:userId", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id, userId } = request.params as { id: string; userId: string };
|
||||
await removeMember(svc, actor, id, userId);
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user