forked from EduCraft/curriculum-project-hub
3f9b60f692
The spec/ Lean semantic master had no conformance gate, no codegen, and no CI tie to implementations — alignment was carried entirely by human review, the same mechanism that carries the ADRs. In practice the ADRs plus greppable code comments were already the load-bearing artifacts, so spec/ was the most expensive kind of stale documentation. - delete spec/ and the spec-check CI workflow - README: constitution rewritten around ADRs as decision truth - AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR, never rewrite ADR history; supersede instead) - code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma) - leave ADR bodies and .scratch audit snapshots untouched (history); fix live references in open readiness tickets
246 lines
8.1 KiB
TypeScript
246 lines
8.1 KiB
TypeScript
/**
|
|
* 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<readonly OrgMemberRow[]> {
|
|
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<OrgMemberRow> {
|
|
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<OrgMemberRow> {
|
|
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<void> {
|
|
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;
|
|
}
|