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