Files
curriculum-project-hub/hub/src/database/routes/memberGroupRoutes.ts
T

197 lines
7.4 KiB
TypeScript

/**
* /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);
}
});
}