forked from EduCraft/curriculum-project-hub
197 lines
7.4 KiB
TypeScript
197 lines
7.4 KiB
TypeScript
/**
|
|
* /database/api/groups/* 成员组管理端点(ADR-0038)。
|
|
* 约定:绝对路径;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-0038)");
|
|
}
|
|
// 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);
|
|
}
|
|
});
|
|
}
|