/** * Org project explorer (ADR-0021). * * Folders are transparent navigation nodes (not ACL resources). Project grants * stay on PROJECT. Archive folder refuses when active children/projects remain. */ import { Prisma, type PrismaClient } from "@prisma/client"; import { archiveFeishuChatBinding, createFolder, createProjectFromOrgAdmin, moveProjectToFolder, } from "../projectOnboarding.js"; import { lockActiveOrganization } from "./status.js"; export interface ExplorerFolderNode { readonly id: string; readonly name: string; readonly parentId: string | null; readonly sortKey: string; readonly projectCount: number; readonly childFolderCount: number; } export interface ExplorerProjectNode { readonly id: string; readonly name: string; readonly folderId: string | null; readonly createdAt: string; readonly binding: { readonly chatId: string; readonly createdAt: string } | null; } export interface OrgExplorer { readonly folders: readonly ExplorerFolderNode[]; readonly projects: readonly ExplorerProjectNode[]; } export async function listOrgExplorer( prisma: PrismaClient, organizationId: string, ): Promise { const [folders, projects] = await Promise.all([ prisma.folder.findMany({ where: { organizationId, archivedAt: null }, select: { id: true, name: true, parentId: true, sortKey: true, _count: { select: { children: { where: { archivedAt: null } }, projects: { where: { archivedAt: null } }, }, }, }, orderBy: [{ sortKey: "asc" }, { name: "asc" }], }), prisma.project.findMany({ where: { organizationId, archivedAt: null }, select: { id: true, name: true, folderId: true, createdAt: true, groupBindings: { where: { archivedAt: null }, select: { chatId: true, createdAt: true }, take: 1, }, }, orderBy: [{ name: "asc" }], }), ]); return { folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId, sortKey: f.sortKey, projectCount: f._count.projects, childFolderCount: f._count.children, })), projects: projects.map((p) => { const binding = p.groupBindings[0]; return { id: p.id, name: p.name, folderId: p.folderId, createdAt: p.createdAt.toISOString(), binding: binding === undefined ? null : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() }, }; }), }; } export async function createOrgFolder( prisma: PrismaClient, input: { readonly organizationId: string; readonly name: string; readonly parentId?: string | undefined; readonly sortKey?: string | undefined; }, ) { return createFolder(prisma, input); } export async function renameFolder( prisma: PrismaClient, input: { readonly organizationId: string; readonly folderId: string; readonly name?: string | undefined; readonly sortKey?: string | undefined; readonly parentId?: string | null | undefined; }, ) { return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); if (folder.kind === "SYSTEM_INBOX" && (input.name !== undefined || input.parentId !== undefined)) { throw new Error("system Inbox cannot be renamed or moved"); } if (input.parentId !== undefined && input.parentId !== null) { if (input.parentId === folder.id) { throw new Error("folder cannot be its own parent"); } await requireActiveFolder(tx, input.parentId, input.organizationId); const descendant = await tx.$queryRaw>(Prisma.sql` WITH RECURSIVE descendants AS ( SELECT "id" FROM "Folder" WHERE "parentId" = ${folder.id} AND "archivedAt" IS NULL UNION ALL SELECT child."id" FROM "Folder" child JOIN descendants parent ON child."parentId" = parent."id" WHERE child."archivedAt" IS NULL ) SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found `); if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant"); } const name = input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined; return tx.folder.update({ where: { id: folder.id }, data: { ...(name !== undefined ? { name } : {}), ...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}), ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), }, }); }); } /** * Soft-archive a folder. Refuses if it still has active child folders or projects. */ export async function archiveFolder( prisma: PrismaClient, input: { readonly organizationId: string; readonly folderId: string }, ): Promise<{ readonly archived: true; readonly folderId: string }> { return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); if (folder.kind === "SYSTEM_INBOX") throw new Error("system Inbox cannot be archived"); const childFolders = await tx.folder.count({ where: { parentId: folder.id, archivedAt: null }, }); if (childFolders > 0) { throw new Error( `cannot archive folder ${folder.id}: still has ${childFolders} active child folder(s)`, ); } const projects = await tx.project.count({ where: { folderId: folder.id, archivedAt: null }, }); if (projects > 0) { throw new Error( `cannot archive folder ${folder.id}: still has ${projects} active project(s)`, ); } await tx.folder.update({ where: { id: folder.id }, data: { archivedAt: new Date() }, }); return { archived: true as const, folderId: folder.id }; }); } export async function createOrgProject( prisma: PrismaClient, input: { readonly organizationId: string; readonly actorFeishuOpenId: string; readonly name: string; readonly workspaceRoot: string; readonly folderId?: string | undefined; }, ) { return createProjectFromOrgAdmin(prisma, input); } export async function renameProject( prisma: PrismaClient, input: { readonly organizationId: string; readonly projectId: string; readonly name: string; }, ) { const name = requireNonEmpty(input.name, "project name"); return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const project = await requireActiveProject(tx, input.projectId, input.organizationId); return tx.project.update({ where: { id: project.id }, data: { name }, select: { id: true, name: true, folderId: true }, }); }); } export async function moveOrgProjectToFolder( prisma: PrismaClient, input: { readonly organizationId: string; readonly projectId: string; readonly folderId: string | null; }, ) { await requireActiveProject(prisma, input.projectId, input.organizationId); return moveProjectToFolder(prisma, { projectId: input.projectId, folderId: input.folderId, }); } export async function archiveProject( prisma: PrismaClient, input: { readonly organizationId: string; readonly projectId: string }, ): Promise<{ readonly archived: true; readonly projectId: string }> { return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const project = await requireActiveProject(tx, input.projectId, input.organizationId); const activeBinding = await tx.projectGroupBinding.findFirst({ where: { projectId: project.id, archivedAt: null }, select: { id: true, chatId: true }, }); const now = new Date(); if (activeBinding !== null) { await tx.projectGroupBinding.update({ where: { id: activeBinding.id }, data: { archivedAt: now }, }); await tx.permissionGrant.updateMany({ where: { resourceType: "PROJECT", resourceId: project.id, principalType: "FEISHU_CHAT", principalId: activeBinding.chatId, revokedAt: null, }, data: { revokedAt: now }, }); } await tx.project.update({ where: { id: project.id }, data: { archivedAt: now }, }); return { archived: true as const, projectId: project.id }; }); } export async function getOrgProjectDetail( prisma: PrismaClient, input: { readonly organizationId: string; readonly projectId: string }, ) { const project = await prisma.project.findFirst({ where: { id: input.projectId, organizationId: input.organizationId, }, select: { id: true, name: true, folderId: true, workspaceDir: true, createdAt: true, archivedAt: true, createdBy: { select: { id: true, displayName: true, feishuOpenId: true } }, folder: { select: { id: true, name: true } }, groupBindings: { where: { archivedAt: null }, select: { chatId: true, createdAt: true }, take: 1, }, }, }); if (project === null) { throw new Error(`project not found: ${input.projectId}`); } const binding = project.groupBindings[0]; return { id: project.id, name: project.name, folderId: project.folderId, folder: project.folder, workspaceDir: project.workspaceDir, createdAt: project.createdAt.toISOString(), archivedAt: project.archivedAt?.toISOString() ?? null, createdBy: project.createdBy, binding: binding === undefined ? null : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() }, }; } export async function archiveOrgProjectChatBinding( prisma: PrismaClient, input: { readonly organizationId: string; readonly projectId: string; readonly actorFeishuOpenId: string; }, ) { await requireActiveProject(prisma, input.projectId, input.organizationId); return archiveFeishuChatBinding(prisma, { projectId: input.projectId, actorFeishuOpenId: input.actorFeishuOpenId, }); } async function requireActiveFolder( prisma: PrismaClient | Prisma.TransactionClient, folderId: string, organizationId: string, ): Promise<{ readonly id: string; readonly organizationId: string; readonly kind: "REGULAR" | "SYSTEM_INBOX" }> { const folder = await prisma.folder.findUnique({ where: { id: folderId }, select: { id: true, organizationId: true, kind: true, archivedAt: true }, }); if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) { throw new Error(`active folder not found: ${folderId}`); } return folder; } async function requireActiveProject( prisma: PrismaClient | Prisma.TransactionClient, projectId: string, organizationId: string, ): Promise<{ readonly id: string; readonly organizationId: string }> { const project = await prisma.project.findUnique({ where: { id: projectId }, select: { id: true, organizationId: true, archivedAt: true }, }); if (project === null || project.archivedAt !== null || project.organizationId !== organizationId) { throw new Error(`active project not found: ${projectId}`); } return project; } function requireNonEmpty(value: string, label: string): string { const trimmed = value.trim(); if (trimmed === "") throw new Error(`${label} is required`); return trimmed; }