/** * Hub team lifecycle for org admin (ADR-0019 / ADR-0021). * * Archiving a team soft-archives the team row and revokes active TEAM→PROJECT * grants that use this team as principal (product pin). */ import type { Prisma, PrismaClient } from "@prisma/client"; 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 existing = await prisma.team.findFirst({ where: { organizationId: input.organizationId, slug, archivedAt: null }, select: { id: true }, }); if (existing !== null) { throw new Error(`team slug already exists: ${slug}`); } const team = await prisma.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 team = await requireActiveTeam(prisma, input.teamId, input.organizationId); const updated = await prisma.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 and revoke its active project grants (TEAM principal). */ export async function archiveTeam( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string }, ): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> { return prisma.$transaction(async (tx) => { const team = await requireActiveTeam(tx, input.teamId, input.organizationId); const now = new Date(); await tx.team.update({ where: { id: team.id }, data: { archivedAt: now }, }); await tx.teamMembership.updateMany({ where: { teamId: team.id, revokedAt: null }, data: { revokedAt: now }, }); const grants = await tx.permissionGrant.updateMany({ where: { principalType: "TEAM", principalId: team.id, revokedAt: null, }, data: { revokedAt: now }, }); return { archived: true as const, teamId: team.id, revokedGrants: grants.count, }; }); } 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 } }, }, orderBy: { createdAt: "asc" }, }); return rows.map((row) => ({ userId: row.user.id, feishuOpenId: 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 team = await requireActiveTeam(prisma, input.teamId, input.organizationId); const user = await resolveUser(prisma, input); // User should be an org member to join a team (product pin for pilot). const membership = await prisma.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 prisma.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 prisma.teamMembership.create({ data: { teamId: team.id, userId: user.id }, }); return { userId: user.id, feishuOpenId: user.feishuOpenId, displayName: user.displayName, createdAt: 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 requireActiveTeam(prisma, input.teamId, input.organizationId); const membership = await prisma.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 prisma.teamMembership.update({ where: { id: membership.id }, data: { revokedAt: new Date() }, }); return { revoked: true as const, userId: input.userId }; } async function resolveUser( prisma: PrismaClient, input: { 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 }, }); if (user === null) throw new Error(`user not found: ${input.userId}`); return user; } if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") { const user = await prisma.user.findUnique({ where: { feishuOpenId: input.feishuOpenId }, select: { id: true, feishuOpenId: true, displayName: true }, }); if (user === null) throw new Error(`user not found: ${input.feishuOpenId}`); return user; } 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; }