/** * Organization membership management for org admin (ADR-0021). * * Role rules (product pin, not yet recorded in an ADR): * 1. Actor must be OWNER or ADMIN (enforced at HTTP layer). * 2. Only OWNER can grant/revoke OWNER or modify another OWNER. * 3. Cannot revoke or demote the last remaining OWNER. * 4. ADMIN cannot modify OWNER memberships. */ import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client"; import { upsertScopedFeishuIdentityInTransaction } from "../feishu/identityNamespace.js"; import { lockActiveOrganization } from "./status.js"; export interface OrgMemberRow { readonly userId: string; readonly feishuOpenId: string | null; readonly identityStatus: "SCOPED" | "UNLINKED"; readonly displayName: string; readonly avatarUrl: string | null; readonly role: OrganizationMemberRole; readonly createdAt: string; } export async function listOrgMembers( prisma: PrismaClient, organizationId: string, ): Promise { const rows = await prisma.organizationMembership.findMany({ where: { organizationId, revokedAt: null }, select: { role: true, createdAt: true, user: { select: { id: true, displayName: true, avatarUrl: true, feishuIdentities: { where: { connection: { organizationId } }, select: { openId: true }, take: 1, }, }, }, }, orderBy: [{ role: "asc" }, { createdAt: "asc" }], }); return rows.map((row) => ({ userId: row.user.id, feishuOpenId: row.user.feishuIdentities[0]?.openId ?? null, identityStatus: row.user.feishuIdentities.length === 1 ? "SCOPED" : "UNLINKED", displayName: row.user.displayName, avatarUrl: row.user.avatarUrl, role: row.role, createdAt: row.createdAt.toISOString(), })); } export async function addOrgMember( prisma: PrismaClient, input: { readonly organizationId: string; readonly actorRole: OrganizationMemberRole; readonly feishuOpenId: string; readonly displayName?: string | undefined; readonly role: OrganizationMemberRole; }, ): Promise { const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId"); assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false); const { identity, membership } = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const connection = await tx.organizationFeishuApplicationConnection.findUnique({ where: { organizationId: input.organizationId }, select: { id: true, status: true, activeSecretVersion: { select: { connectionId: true, retiredAt: true } }, }, }); if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null || connection.activeSecretVersion.connectionId !== connection.id || connection.activeSecretVersion.retiredAt !== null) { throw new Error("active Feishu Application Connection not found"); } const identity = await upsertScopedFeishuIdentityInTransaction(tx, { connectionId: connection.id, expectedOrganizationId: input.organizationId, openId, displayName: input.displayName?.trim() || openId, }); const existing = await tx.organizationMembership.findFirst({ where: { organizationId: input.organizationId, userId: identity.userId, revokedAt: null }, }); if (existing !== null) throw new Error(`user ${identity.userId} is already an active member`); const membership = await tx.organizationMembership.create({ data: { organizationId: input.organizationId, userId: identity.userId, role: input.role, }, }); return { identity, membership }; }); return { userId: identity.userId, feishuOpenId: identity.openId, identityStatus: "SCOPED", displayName: identity.displayName, avatarUrl: identity.avatarUrl, role: membership.role, createdAt: membership.createdAt.toISOString(), }; } export async function setOrgMemberRole( prisma: PrismaClient, input: { readonly organizationId: string; readonly actorUserId: string; readonly actorRole: OrganizationMemberRole; readonly targetUserId: string; readonly role: OrganizationMemberRole; }, ): Promise { return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const membership = await tx.organizationMembership.findFirst({ where: { organizationId: input.organizationId, userId: input.targetUserId, revokedAt: null, }, select: { id: true, role: true, createdAt: true, user: { select: { id: true, displayName: true, avatarUrl: true, feishuIdentities: { where: { connection: { organizationId: input.organizationId } }, select: { openId: true }, take: 1, }, }, }, }, }); if (membership === null) throw new Error(`member not found: ${input.targetUserId}`); assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER"); if (membership.role === "OWNER" && input.actorRole !== "OWNER") { throw new Error("cannot modify OWNER membership as ADMIN"); } if (membership.role === "OWNER" && input.role !== "OWNER") { await assertNotLastOwner(tx, input.organizationId, input.targetUserId); } const updated = await tx.organizationMembership.update({ where: { id: membership.id }, data: { role: input.role }, }); return { userId: membership.user.id, feishuOpenId: membership.user.feishuIdentities[0]?.openId ?? null, identityStatus: membership.user.feishuIdentities.length === 1 ? "SCOPED" : "UNLINKED", displayName: membership.user.displayName, avatarUrl: membership.user.avatarUrl, role: updated.role, createdAt: membership.createdAt.toISOString(), }; }); } export async function revokeOrgMember( prisma: PrismaClient, input: { readonly organizationId: string; readonly actorUserId: string; readonly actorRole: OrganizationMemberRole; readonly targetUserId: string; }, ): Promise<{ readonly revoked: true; readonly userId: string }> { await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const membership = await tx.organizationMembership.findFirst({ where: { organizationId: input.organizationId, userId: input.targetUserId, revokedAt: null, }, select: { id: true, role: true }, }); if (membership === null) throw new Error(`member not found: ${input.targetUserId}`); if (membership.role === "OWNER" && input.actorRole !== "OWNER") { throw new Error("cannot revoke OWNER membership as ADMIN"); } if (membership.role === "OWNER") { await assertNotLastOwner(tx, input.organizationId, input.targetUserId); } await tx.organizationMembership.update({ where: { id: membership.id }, data: { revokedAt: new Date() }, }); }); return { revoked: true as const, userId: input.targetUserId }; } function assertCanAssignRole( actorRole: OrganizationMemberRole, newRole: OrganizationMemberRole, targetIsOwner: boolean, ): void { if (newRole === "OWNER" && actorRole !== "OWNER") { throw new Error("only OWNER can grant OWNER role"); } if (targetIsOwner && actorRole !== "OWNER") { throw new Error("cannot modify OWNER membership as ADMIN"); } } async function assertNotLastOwner( prisma: PrismaClient | Prisma.TransactionClient, organizationId: string, targetUserId: string, ): Promise { const owners = await prisma.organizationMembership.count({ where: { organizationId, role: "OWNER", revokedAt: null }, }); if (owners <= 1) { throw new Error(`cannot revoke or demote last OWNER (${targetUserId})`); } } function requireNonEmpty(value: string, label: string): string { const trimmed = value.trim(); if (trimmed === "") throw new Error(`${label} is required`); return trimmed; }