forked from EduCraft/curriculum-project-hub
608 lines
21 KiB
TypeScript
608 lines
21 KiB
TypeScript
/**
|
|
* 成员组(MemberGroup)管理服务(ADR-0028)。
|
|
*
|
|
* 语义锚定:
|
|
* - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org
|
|
* (deps.organizationId)—— MemberGroup 无 orgId,审计沿用文件库 sink(决策4)。
|
|
* - 权限门禁:创建/删除/成员增删仅网站管理员(silo org OWNER/ADMIN);
|
|
* 搜索(授权选择器)不限管理员 —— 选组授权是 Manage 持有者的能力(决策2)。
|
|
* - 软删除:archivedAt 打标;删组级联软删整棵子树(闭包 ancestorId=G);
|
|
* 闭包/成员行保留,list/解析按 archivedAt 过滤(决策4)。
|
|
* - 闭包维护:仅 create —— 插 (G,G,0),再对 parent P 插
|
|
* (a.ancestorId, G, a.depth+1) for a in closure where descendantId=P。
|
|
* v1 不支持 reparent(决策5)。
|
|
*
|
|
* 与 hub Team 不同:成员是全局用户,不要求 org membership;按 userId 或
|
|
* User.feishuOpenId(全局 @unique)解析。
|
|
*/
|
|
|
|
import type { PrismaClient, Prisma } from "@prisma/client";
|
|
import { FileLibError } from "./model.js";
|
|
import type { FileLibActor } from "./treeService.js";
|
|
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
|
|
|
export interface MemberGroupServiceDeps {
|
|
readonly prisma: PrismaClient;
|
|
/** silo org id —— 仅用于审计归属(MemberGroup 全局无 orgId,决策4)。 */
|
|
readonly organizationId: string;
|
|
}
|
|
|
|
export interface MemberGroupDto {
|
|
readonly id: string;
|
|
readonly parentId: string | null;
|
|
readonly name: string;
|
|
readonly description: string | null;
|
|
/** 到根的边数(根 = 0);由闭包行数推导。 */
|
|
readonly depth: number;
|
|
readonly memberCount: number;
|
|
/** 软删标记(决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 */
|
|
readonly archivedAt: Date | null;
|
|
}
|
|
|
|
export interface MemberGroupMemberDto {
|
|
readonly userId: string;
|
|
readonly displayName: string;
|
|
readonly feishuOpenId: string;
|
|
readonly avatarUrl: string | null;
|
|
/** 加入本组时间(membership.createdAt),用于成员表排序/展示。 */
|
|
readonly joinedAt: Date;
|
|
}
|
|
|
|
/** 成员选择器候选(加成员弹窗搜索用)。 */
|
|
export interface UserSearchResult {
|
|
readonly userId: string;
|
|
readonly displayName: string;
|
|
readonly feishuOpenId: string;
|
|
readonly avatarUrl: string | null;
|
|
}
|
|
|
|
export interface MemberGroupSearchResult {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
/** 祖先链(根在前,自身在末),用 " / " 连接;无祖先时即自身名。 */
|
|
readonly breadcrumb: string;
|
|
}
|
|
|
|
export interface CreateMemberGroupInput {
|
|
readonly name: string;
|
|
readonly description?: string | undefined;
|
|
readonly parentId?: string | null | undefined;
|
|
}
|
|
|
|
export interface AddMemberInput {
|
|
readonly userId?: string | undefined;
|
|
readonly feishuOpenId?: string | undefined;
|
|
}
|
|
|
|
/** 改名/改描述(决策6)。字段缺省 = 不动;description 传空串 = 清空。 */
|
|
export interface UpdateMemberGroupInput {
|
|
readonly name?: string | undefined;
|
|
readonly description?: string | undefined;
|
|
}
|
|
|
|
type Tx = Prisma.TransactionClient;
|
|
|
|
/* ---------------------------------------------------------------- 内部工具 */
|
|
|
|
/** 管理门禁:非网站管理员一律 403(决策2)。 */
|
|
function requireAdmin(actor: FileLibActor): void {
|
|
if (!actor.isWebsiteAdmin) {
|
|
throw new FileLibError(403, "forbidden", "group management requires website administrator");
|
|
}
|
|
}
|
|
|
|
/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */
|
|
function normalizeGroupName(raw: string): string {
|
|
const name = raw.trim();
|
|
if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty");
|
|
if (name.length > 100) throw new FileLibError(400, "invalid_request", "group name too long (max 100)");
|
|
return name;
|
|
}
|
|
|
|
async function requireActiveGroup(
|
|
client: PrismaClient | Tx,
|
|
groupId: string,
|
|
): Promise<{ readonly id: string; readonly name: string }> {
|
|
const group = await client.memberGroup.findFirst({
|
|
where: { id: groupId, archivedAt: null },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
|
|
return group;
|
|
}
|
|
|
|
/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */
|
|
async function resolveUser(
|
|
tx: Tx,
|
|
input: AddMemberInput,
|
|
): Promise<{
|
|
readonly id: string;
|
|
readonly displayName: string;
|
|
readonly feishuOpenId: string;
|
|
readonly avatarUrl: string | null;
|
|
}> {
|
|
const select = { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } as const;
|
|
if (input.userId !== undefined && input.userId !== "") {
|
|
const user = await tx.user.findUnique({ where: { id: input.userId }, select });
|
|
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.userId}`);
|
|
return user;
|
|
}
|
|
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
|
|
const user = await tx.user.findUnique({
|
|
where: { feishuOpenId: input.feishuOpenId },
|
|
select,
|
|
});
|
|
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.feishuOpenId}`);
|
|
return user;
|
|
}
|
|
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- 公共操作 */
|
|
|
|
/**
|
|
* 创建成员组(建根 / 建子)。仅网站管理员。事务内维护闭包。
|
|
* parentId 给定时校验其活跃存在;闭包:插自身 depth0 + 继承 parent 的祖先。
|
|
*/
|
|
export async function createMemberGroup(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
input: CreateMemberGroupInput,
|
|
): Promise<MemberGroupDto> {
|
|
requireAdmin(actor);
|
|
const name = normalizeGroupName(input.name);
|
|
const description = input.description?.trim() || null;
|
|
const parentId = input.parentId ?? null;
|
|
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
let parentClosure: { ancestorId: string; depth: number }[] = [];
|
|
if (parentId !== null) {
|
|
const parent = await tx.memberGroup.findFirst({
|
|
where: { id: parentId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (parent === null) throw new FileLibError(404, "group_not_found", "parent group not found");
|
|
parentClosure = await tx.memberGroupClosure.findMany({
|
|
where: { descendantId: parentId },
|
|
select: { ancestorId: true, depth: true },
|
|
});
|
|
}
|
|
|
|
const group = await tx.memberGroup.create({
|
|
data: { name, parentId, ...(description !== null ? { description } : {}) },
|
|
select: { id: true, parentId: true, name: true, description: true },
|
|
});
|
|
|
|
// 闭包维护:自身 depth0,再继承 parent 的每个祖先(depth+1)。
|
|
await tx.memberGroupClosure.create({
|
|
data: { ancestorId: group.id, descendantId: group.id, depth: 0 },
|
|
});
|
|
if (parentClosure.length > 0) {
|
|
await tx.memberGroupClosure.createMany({
|
|
data: parentClosure.map((a) => ({
|
|
ancestorId: a.ancestorId,
|
|
descendantId: group.id,
|
|
depth: a.depth + 1,
|
|
})),
|
|
});
|
|
}
|
|
// parent 的闭包行数 = parent.depth + 1 = 新组 depth(闭包不变量)。
|
|
const depth = parentClosure.length;
|
|
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.groupCreate,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "group",
|
|
objectId: group.id,
|
|
objectPath: group.id,
|
|
detail: { name, parentId },
|
|
});
|
|
|
|
return {
|
|
id: group.id,
|
|
parentId: group.parentId,
|
|
name: group.name,
|
|
description: group.description,
|
|
depth,
|
|
memberCount: 0,
|
|
archivedAt: null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1
|
|
* 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。
|
|
*/
|
|
export async function updateMemberGroup(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
groupId: string,
|
|
input: UpdateMemberGroupInput,
|
|
): Promise<MemberGroupDto> {
|
|
requireAdmin(actor);
|
|
if (input.name === undefined && input.description === undefined) {
|
|
throw new FileLibError(400, "invalid_request", "name or description is required");
|
|
}
|
|
const name = input.name === undefined ? undefined : normalizeGroupName(input.name);
|
|
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
await requireActiveGroup(tx, groupId);
|
|
const group = await tx.memberGroup.update({
|
|
where: { id: groupId },
|
|
data: {
|
|
...(name !== undefined ? { name } : {}),
|
|
...(input.description !== undefined
|
|
? { description: input.description.trim() || null }
|
|
: {}),
|
|
},
|
|
select: { id: true, parentId: true, name: true, description: true },
|
|
});
|
|
|
|
// depth 由闭包行数推导(与 listMemberGroups 同一不变量);update 不改闭包。
|
|
const closureCount = await tx.memberGroupClosure.count({ where: { descendantId: groupId } });
|
|
const memberCount = await tx.memberGroupMembership.count({
|
|
where: { groupId, revokedAt: null },
|
|
});
|
|
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "group",
|
|
objectId: group.id,
|
|
objectPath: group.id,
|
|
detail: {
|
|
...(name !== undefined ? { name } : {}),
|
|
...(input.description !== undefined ? { description: group.description } : {}),
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: group.id,
|
|
parentId: group.parentId,
|
|
name: group.name,
|
|
description: group.description,
|
|
depth: closureCount - 1,
|
|
memberCount,
|
|
archivedAt: null, // requireActiveGroup 已保证是活跃组
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 软删除成员组:级联软删整棵子树(闭包 ancestorId=G 的全部活跃 descendant)。
|
|
* 闭包/成员行保留;list/解析按 archivedAt 过滤,整支立即停止贡献权限。
|
|
*/
|
|
export async function deleteMemberGroup(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
groupId: string,
|
|
): Promise<{ readonly archivedCount: number }> {
|
|
requireAdmin(actor);
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const group = await requireActiveGroup(tx, groupId);
|
|
const subtree = await tx.memberGroupClosure.findMany({
|
|
where: { ancestorId: groupId },
|
|
select: { descendantId: true },
|
|
});
|
|
const ids = subtree.map((r) => r.descendantId);
|
|
const now = new Date();
|
|
const result = await tx.memberGroup.updateMany({
|
|
where: { id: { in: ids }, archivedAt: null },
|
|
data: { archivedAt: now },
|
|
});
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "group",
|
|
objectId: group.id,
|
|
objectPath: group.id,
|
|
detail: { name: group.name, archivedCount: result.count },
|
|
});
|
|
return { archivedCount: result.count };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7):
|
|
* - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。
|
|
* - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、
|
|
* depth 推导(闭包行数)与"祖先必活跃"的前提脱节。
|
|
* - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次
|
|
* 恢复意外把整支历史组全部重新授权。
|
|
* 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。
|
|
*/
|
|
export async function restoreMemberGroup(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
groupId: string,
|
|
): Promise<{ readonly restoredCount: number }> {
|
|
requireAdmin(actor);
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const group = await tx.memberGroup.findUnique({
|
|
where: { id: groupId },
|
|
select: { id: true, name: true, archivedAt: true },
|
|
});
|
|
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
|
|
if (group.archivedAt === null) {
|
|
throw new FileLibError(409, "not_archived", "group is not archived");
|
|
}
|
|
|
|
// 自身 + 祖先(闭包 descendantId=G 含 depth0 自身),只挑已归档的解标。
|
|
const chain = await tx.memberGroupClosure.findMany({
|
|
where: { descendantId: groupId },
|
|
select: { ancestorId: true },
|
|
});
|
|
const ids = chain.map((r) => r.ancestorId);
|
|
const result = await tx.memberGroup.updateMany({
|
|
where: { id: { in: ids }, archivedAt: { not: null } },
|
|
data: { archivedAt: null },
|
|
});
|
|
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "group",
|
|
objectId: group.id,
|
|
objectPath: group.id,
|
|
detail: { name: group.name, restoredCount: result.count },
|
|
});
|
|
return { restoredCount: result.count };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 组扁平列表(前端自行按 parentId/depth 拼树);仅网站管理员。
|
|
* includeArchived=true 时连已归档组一并返回(带 archivedAt 标记),供后台展示/恢复;
|
|
* 默认只返回活跃组 —— 权限相关的调用方一律走默认。
|
|
*/
|
|
export async function listMemberGroups(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
includeArchived = false,
|
|
): Promise<readonly MemberGroupDto[]> {
|
|
requireAdmin(actor);
|
|
const groups = await deps.prisma.memberGroup.findMany({
|
|
where: includeArchived ? {} : { archivedAt: null },
|
|
orderBy: { name: "asc" },
|
|
select: { id: true, parentId: true, name: true, description: true, archivedAt: true },
|
|
});
|
|
if (groups.length === 0) return [];
|
|
const ids = groups.map((g) => g.id);
|
|
|
|
// depth:每个组的闭包行数(自身 + 祖先)- 1。级联软删保证活跃组的祖先必活跃。
|
|
const closure = await deps.prisma.memberGroupClosure.findMany({
|
|
where: { descendantId: { in: ids } },
|
|
select: { descendantId: true },
|
|
});
|
|
const closureCount = new Map<string, number>();
|
|
for (const row of closure) {
|
|
closureCount.set(row.descendantId, (closureCount.get(row.descendantId) ?? 0) + 1);
|
|
}
|
|
|
|
const counts = await deps.prisma.memberGroupMembership.groupBy({
|
|
by: ["groupId"],
|
|
where: { groupId: { in: ids }, revokedAt: null },
|
|
_count: { _all: true },
|
|
});
|
|
const countByGroup = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
|
|
|
return groups.map((g) => ({
|
|
id: g.id,
|
|
parentId: g.parentId,
|
|
name: g.name,
|
|
description: g.description,
|
|
depth: (closureCount.get(g.id) ?? 1) - 1,
|
|
memberCount: countByGroup.get(g.id) ?? 0,
|
|
archivedAt: g.archivedAt,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在,
|
|
* 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。
|
|
*/
|
|
export async function listMembers(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
groupId: string,
|
|
): Promise<readonly MemberGroupMemberDto[]> {
|
|
requireAdmin(actor);
|
|
const exists = await deps.prisma.memberGroup.findUnique({
|
|
where: { id: groupId },
|
|
select: { id: true },
|
|
});
|
|
if (exists === null) throw new FileLibError(404, "group_not_found", "group not found");
|
|
const rows = await deps.prisma.memberGroupMembership.findMany({
|
|
where: { groupId, revokedAt: null },
|
|
select: {
|
|
createdAt: true,
|
|
user: { select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } },
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
return rows.map((r) => ({
|
|
userId: r.user.id,
|
|
displayName: r.user.displayName,
|
|
feishuOpenId: r.user.feishuOpenId,
|
|
avatarUrl: r.user.avatarUrl,
|
|
joinedAt: r.createdAt,
|
|
}));
|
|
}
|
|
|
|
/** 加成员(userId 或 feishuOpenId 解析);已是活跃成员 → 409。仅网站管理员。 */
|
|
export async function addMember(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
groupId: string,
|
|
input: AddMemberInput,
|
|
): Promise<MemberGroupMemberDto> {
|
|
requireAdmin(actor);
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const group = await requireActiveGroup(tx, groupId);
|
|
const user = await resolveUser(tx, input);
|
|
const existing = await tx.memberGroupMembership.findFirst({
|
|
where: { groupId: group.id, userId: user.id, revokedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (existing !== null) {
|
|
throw new FileLibError(409, "already_member", "user is already a member of this group");
|
|
}
|
|
const created = await tx.memberGroupMembership.create({
|
|
data: { groupId: group.id, userId: user.id },
|
|
select: { createdAt: true },
|
|
});
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "group",
|
|
objectId: group.id,
|
|
objectPath: group.id,
|
|
detail: { userId: user.id },
|
|
});
|
|
return {
|
|
userId: user.id,
|
|
displayName: user.displayName,
|
|
feishuOpenId: user.feishuOpenId,
|
|
avatarUrl: user.avatarUrl,
|
|
joinedAt: created.createdAt,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 移成员(软删 revokedAt);不在组 → 404。仅网站管理员。 */
|
|
export async function removeMember(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
groupId: string,
|
|
userId: string,
|
|
): Promise<void> {
|
|
requireAdmin(actor);
|
|
await deps.prisma.$transaction(async (tx) => {
|
|
const group = await requireActiveGroup(tx, groupId);
|
|
const membership = await tx.memberGroupMembership.findFirst({
|
|
where: { groupId: group.id, userId, revokedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (membership === null) throw new FileLibError(404, "member_not_found", "group member not found");
|
|
await tx.memberGroupMembership.update({
|
|
where: { id: membership.id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "group",
|
|
objectId: group.id,
|
|
objectPath: group.id,
|
|
detail: { userId },
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2)
|
|
* —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大
|
|
* 已有能力面,只是把"盲敲 id"变成"搜索选择"。
|
|
* excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。
|
|
*/
|
|
export async function searchUsers(
|
|
deps: MemberGroupServiceDeps,
|
|
actor: FileLibActor,
|
|
q: string,
|
|
excludeGroupId?: string,
|
|
limit = 20,
|
|
): Promise<readonly UserSearchResult[]> {
|
|
requireAdmin(actor);
|
|
const keyword = q.trim();
|
|
|
|
let excludeIds: string[] = [];
|
|
if (excludeGroupId !== undefined && excludeGroupId !== "") {
|
|
const rows = await deps.prisma.memberGroupMembership.findMany({
|
|
where: { groupId: excludeGroupId, revokedAt: null },
|
|
select: { userId: true },
|
|
});
|
|
excludeIds = rows.map((r) => r.userId);
|
|
}
|
|
|
|
const users = await deps.prisma.user.findMany({
|
|
where: {
|
|
...(excludeIds.length > 0 ? { id: { notIn: excludeIds } } : {}),
|
|
...(keyword === ""
|
|
? {}
|
|
: {
|
|
OR: [
|
|
{ displayName: { contains: keyword, mode: "insensitive" as const } },
|
|
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
|
|
],
|
|
}),
|
|
},
|
|
take: limit,
|
|
orderBy: { displayName: "asc" },
|
|
select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true },
|
|
});
|
|
|
|
return users.map((u) => ({
|
|
userId: u.id,
|
|
displayName: u.displayName,
|
|
feishuOpenId: u.feishuOpenId,
|
|
avatarUrl: u.avatarUrl,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 授权选择器搜索(契约 C2 /groups/search)。**不限管理员**(决策2)。
|
|
* 活跃组按名过滤,breadcrumb 由活跃祖先链按 depth 排序拼成。
|
|
*/
|
|
export async function searchMemberGroups(
|
|
deps: MemberGroupServiceDeps,
|
|
q: string,
|
|
limit = 20,
|
|
): Promise<readonly MemberGroupSearchResult[]> {
|
|
const keyword = q.trim();
|
|
const groups = await deps.prisma.memberGroup.findMany({
|
|
where: {
|
|
archivedAt: null,
|
|
...(keyword === "" ? {} : { name: { contains: keyword, mode: "insensitive" as const } }),
|
|
},
|
|
take: limit,
|
|
orderBy: { name: "asc" },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (groups.length === 0) return [];
|
|
const ids = groups.map((g) => g.id);
|
|
|
|
// 祖先链(仅活跃祖先);depth 越大越靠根。
|
|
const closure = await deps.prisma.memberGroupClosure.findMany({
|
|
where: { descendantId: { in: ids }, ancestor: { archivedAt: null } },
|
|
select: { descendantId: true, ancestorId: true, depth: true },
|
|
});
|
|
const ancestorIds = [...new Set(closure.map((c) => c.ancestorId))];
|
|
const names = await deps.prisma.memberGroup.findMany({
|
|
where: { id: { in: ancestorIds } },
|
|
select: { id: true, name: true },
|
|
});
|
|
const nameById = new Map(names.map((n) => [n.id, n.name]));
|
|
const chainByGroup = new Map<string, { ancestorId: string; depth: number }[]>();
|
|
for (const row of closure) {
|
|
const arr = chainByGroup.get(row.descendantId) ?? [];
|
|
arr.push({ ancestorId: row.ancestorId, depth: row.depth });
|
|
chainByGroup.set(row.descendantId, arr);
|
|
}
|
|
|
|
return groups.map((g) => {
|
|
const chain = (chainByGroup.get(g.id) ?? []).slice().sort((a, b) => b.depth - a.depth);
|
|
const breadcrumb = chain
|
|
.map((c) => nameById.get(c.ancestorId) ?? "")
|
|
.filter((s) => s !== "")
|
|
.join(" / ");
|
|
return { id: g.id, name: g.name, breadcrumb: breadcrumb || g.name };
|
|
});
|
|
}
|