/** * Hub team lifecycle for org admin (ADR-0019 / ADR-0021). * * Archiving a team soft-archives the team row. Active TEAM→PROJECT grants and * memberships are left in place as dead rows: principal resolution refuses * archived teams (see `permissions/principals.ts`), so they confer no access. */ import type { Prisma, PrismaClient } from "@prisma/client"; import { lockActiveOrganization } from "./status.js"; export interface TeamRow { readonly id: string; readonly slug: string; readonly name: string; readonly description: string | null; readonly memberCount: number; readonly createdAt: string; } export interface TeamMemberRow { readonly userId: string; readonly feishuOpenId: string; readonly displayName: string; readonly createdAt: string; } export async function listOrgTeams( prisma: PrismaClient, organizationId: string, ): Promise { const teams = await prisma.team.findMany({ where: { organizationId, archivedAt: null }, select: { id: true, slug: true, name: true, description: true, createdAt: true, _count: { select: { memberships: { where: { revokedAt: null } } } }, }, orderBy: { slug: "asc" }, }); return teams.map((t) => ({ id: t.id, slug: t.slug, name: t.name, description: t.description, memberCount: t._count.memberships, createdAt: t.createdAt.toISOString(), })); } export async function createTeam( prisma: PrismaClient, input: { readonly organizationId: string; readonly slug: string; readonly name: string; readonly description?: string | undefined; }, ): Promise { const slug = sanitizeSlug(input.slug); const name = requireNonEmpty(input.name, "name"); const team = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const existing = await tx.team.findFirst({ where: { organizationId: input.organizationId, slug, archivedAt: null }, select: { id: true }, }); if (existing !== null) throw new Error(`team slug already exists: ${slug}`); return tx.team.create({ data: { organizationId: input.organizationId, slug, name, ...(input.description !== undefined ? { description: input.description } : {}), }, }); }); return { id: team.id, slug: team.slug, name: team.name, description: team.description, memberCount: 0, createdAt: team.createdAt.toISOString(), }; } export async function updateTeam( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string; readonly name?: string | undefined; readonly description?: string | null | undefined; }, ): Promise { const updated = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const team = await requireActiveTeam(tx, input.teamId, input.organizationId); return tx.team.update({ where: { id: team.id }, data: { ...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}), ...(input.description !== undefined ? { description: input.description } : {}), }, select: { id: true, slug: true, name: true, description: true, createdAt: true, _count: { select: { memberships: { where: { revokedAt: null } } } }, }, }); }); return { id: updated.id, slug: updated.slug, name: updated.name, description: updated.description, memberCount: updated._count.memberships, createdAt: updated.createdAt.toISOString(), }; } /** * Soft-archive team. Active grants/memberships are not cascade-revoked; the * archived flag alone makes the team's principal unresolvable (no access). */ export async function archiveTeam( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string }, ): Promise<{ readonly archived: true; readonly teamId: string }> { return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const team = await requireActiveTeam(tx, input.teamId, input.organizationId); await tx.team.update({ where: { id: team.id }, data: { archivedAt: new Date() }, }); return { archived: true as const, teamId: team.id }; }); } export async function listTeamMembers( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string }, ): Promise { await requireActiveTeam(prisma, input.teamId, input.organizationId); const rows = await prisma.teamMembership.findMany({ where: { teamId: input.teamId, revokedAt: null }, select: { createdAt: true, user: { select: { id: true, feishuOpenId: true, displayName: true, feishuIdentities: { where: { connection: { organizationId: input.organizationId } }, select: { openId: true }, take: 1, }, }, }, }, orderBy: { createdAt: "asc" }, }); return rows.map((row) => { const identity = row.user.feishuIdentities[0]; return { userId: row.user.id, feishuOpenId: identity?.openId ?? row.user.feishuOpenId, displayName: row.user.displayName, createdAt: row.createdAt.toISOString(), }; }); } export async function addTeamMember( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string; readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined; }, ): Promise { const result = await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const team = await requireActiveTeam(tx, input.teamId, input.organizationId); const user = await resolveUser(tx, input); // User should be an org member to join a team (product pin for pilot). const membership = await tx.organizationMembership.findFirst({ where: { organizationId: input.organizationId, userId: user.id, revokedAt: null, }, select: { id: true }, }); if (membership === null) throw new Error(`user ${user.id} is not an active member of the organization`); const existing = await tx.teamMembership.findFirst({ where: { teamId: team.id, userId: user.id, revokedAt: null }, }); if (existing !== null) throw new Error(`user ${user.id} is already on team ${team.id}`); const row = await tx.teamMembership.create({ data: { teamId: team.id, userId: user.id } }); return { user, row }; }); return { userId: result.user.id, feishuOpenId: result.user.feishuOpenId, displayName: result.user.displayName, createdAt: result.row.createdAt.toISOString(), }; } export async function revokeTeamMember( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string; readonly userId: string; }, ): Promise<{ readonly revoked: true; readonly userId: string }> { await prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); await requireActiveTeam(tx, input.teamId, input.organizationId); const membership = await tx.teamMembership.findFirst({ where: { teamId: input.teamId, userId: input.userId, revokedAt: null }, select: { id: true }, }); if (membership === null) throw new Error(`team member not found: ${input.userId}`); await tx.teamMembership.update({ where: { id: membership.id }, data: { revokedAt: new Date() }, }); }); return { revoked: true as const, userId: input.userId }; } async function resolveUser( prisma: PrismaClient | Prisma.TransactionClient, input: { readonly organizationId: string; readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined; }, ): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> { if (input.userId !== undefined && input.userId !== "") { const user = await prisma.user.findUnique({ where: { id: input.userId }, select: { id: true, feishuOpenId: true, displayName: true, feishuIdentities: { where: { connection: { organizationId: input.organizationId } }, select: { openId: true }, take: 1, }, }, }); if (user === null) throw new Error(`user not found: ${input.userId}`); const identity = user.feishuIdentities[0]; return { id: user.id, feishuOpenId: identity?.openId ?? user.feishuOpenId, displayName: user.displayName, }; } if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") { const identity = await prisma.feishuUserIdentity.findFirst({ where: { openId: input.feishuOpenId, connection: { organizationId: input.organizationId, status: "ACTIVE" }, }, select: { openId: true, user: { select: { id: true, displayName: true } } }, }); if (identity === null) throw new Error(`user not found in organization: ${input.feishuOpenId}`); return { id: identity.user.id, feishuOpenId: identity.openId, displayName: identity.user.displayName }; } throw new Error("userId or feishuOpenId is required"); } async function requireActiveTeam( prisma: PrismaClient | Prisma.TransactionClient, teamId: string, organizationId: string, ): Promise<{ readonly id: string }> { const team = await prisma.team.findUnique({ where: { id: teamId }, select: { id: true, organizationId: true, archivedAt: true }, }); if (team === null || team.archivedAt !== null || team.organizationId !== organizationId) { throw new Error(`active team not found: ${teamId}`); } return team; } function sanitizeSlug(raw: string): string { const slug = requireNonEmpty(raw, "slug").toLowerCase(); if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) { throw new Error("slug must be lowercase alphanumeric with optional hyphens"); } return slug; } function requireNonEmpty(value: string, label: string): string { const trimmed = value.trim(); if (trimmed === "") throw new Error(`${label} is required`); return trimmed; }