import type { Prisma, PrismaClient, PrincipalType } from "@prisma/client"; import { lockActiveOrganization } from "../org/status.js"; export type ExternalPrincipalType = Extract< PrincipalType, "FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP" >; export interface ExternalPrincipalMembershipInput { readonly feishuOpenId: string; readonly displayName: string; readonly principalType: ExternalPrincipalType; readonly principalId: string; } export interface SyncExternalPrincipalMembershipsInput { readonly organizationId: string; readonly provider?: string | undefined; readonly providerTenantId?: string | undefined; readonly source: string; readonly replaceSource?: boolean | undefined; readonly memberships: readonly ExternalPrincipalMembershipInput[]; } export interface SyncExternalPrincipalMembershipsResult { readonly synced: number; readonly revoked: number; } export async function syncExternalPrincipalMemberships( prisma: PrismaClient, input: SyncExternalPrincipalMembershipsInput, ): Promise { const syncedAt = new Date(); const incomingKeys = new Set(); let synced = 0; const connection = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); return tx.externalDirectoryConnection.upsert({ where: { organizationId_provider_source: { organizationId: input.organizationId, provider: input.provider ?? "FEISHU", source: input.source, }, }, update: { revokedAt: null, ...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}), }, create: { organizationId: input.organizationId, provider: input.provider ?? "FEISHU", ...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}), source: input.source, }, select: { id: true }, }); }); // Each membership is a short lifecycle-serialized unit. A large directory // must not hold the Organization row lock (or one interactive transaction) // for the duration of the entire external sync. for (const item of input.memberships) { assertExternalPrincipalType(item.principalType); const userId = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const user = await tx.user.upsert({ where: { feishuOpenId: item.feishuOpenId }, update: { displayName: item.displayName }, create: { feishuOpenId: item.feishuOpenId, displayName: item.displayName, platformRoles: { create: { role: "TEACHER" } }, organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } }, }, }); await ensureOrganizationMembership(tx, input.organizationId, user.id); const existing = await tx.externalPrincipalMembership.findFirst({ where: { userId: user.id, principalType: item.principalType, principalId: item.principalId, connectionId: connection.id, revokedAt: null, }, select: { id: true }, }); if (existing === null) { await tx.externalPrincipalMembership.create({ data: { userId: user.id, connectionId: connection.id, principalType: item.principalType, principalId: item.principalId, syncedAt, }, }); } else { await tx.externalPrincipalMembership.update({ where: { id: existing.id }, data: { syncedAt }, }); } return user.id; }); incomingKeys.add(externalMembershipKey(userId, item.principalType, item.principalId, connection.id)); synced++; } let revoked = 0; if (input.replaceSource === true) { revoked = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const active = await tx.externalPrincipalMembership.findMany({ where: { connectionId: connection.id, revokedAt: null }, select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true }, }); const revokeIds = active .filter((membership) => !incomingKeys.has(externalMembershipKey( membership.userId, membership.principalType, membership.principalId, membership.connectionId, ))) .map((membership) => membership.id); if (revokeIds.length > 0) { const result = await tx.externalPrincipalMembership.updateMany({ where: { id: { in: revokeIds } }, data: { revokedAt: syncedAt }, }); return result.count; } return 0; }); } return { synced, revoked }; } async function ensureOrganizationMembership( prisma: PrismaClient | Prisma.TransactionClient, organizationId: string, userId: string, ): Promise { const active = await prisma.organizationMembership.findFirst({ where: { organizationId, userId, revokedAt: null }, select: { id: true }, }); if (active !== null) return; await prisma.organizationMembership.create({ data: { organizationId, userId, role: "MEMBER" }, }); } function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string { return `${connectionId}:${userId}:${type}:${id}`; } function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType { if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") { throw new Error(`unsupported external principal type: ${type}`); } }