feat: add org admin project onboarding foundation

This commit is contained in:
2026-07-10 00:25:00 +08:00
parent 2b7aa3294f
commit 34e07e229f
16 changed files with 1072 additions and 38 deletions
@@ -0,0 +1,92 @@
-- ADR-0021: org admin project onboarding.
-- Folder is a transparent project explorer node; project remains the permission boundary.
CREATE TABLE "OrganizationProjectSettings" (
"organizationId" TEXT NOT NULL,
"membersCanCreateProjects" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationProjectSettings_pkey" PRIMARY KEY ("organizationId")
);
ALTER TABLE "OrganizationProjectSettings"
ADD CONSTRAINT "OrganizationProjectSettings_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "OrganizationProjectSettings" ("organizationId", "membersCanCreateProjects", "createdAt", "updatedAt")
SELECT "id", true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization"
ON CONFLICT ("organizationId") DO NOTHING;
CREATE TABLE "Folder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"sortKey" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "Folder"
ADD CONSTRAINT "Folder_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Folder"
ADD CONSTRAINT "Folder_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "Folder_organizationId_parentId_archivedAt_idx"
ON "Folder"("organizationId", "parentId", "archivedAt");
CREATE INDEX "Folder_organizationId_parentId_sortKey_idx"
ON "Folder"("organizationId", "parentId", "sortKey");
CREATE UNIQUE INDEX "Folder_active_sibling_name_key"
ON "Folder"("organizationId", COALESCE("parentId", ''), lower("name"))
WHERE "archivedAt" IS NULL;
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "sortKey", "createdAt", "updatedAt")
SELECT 'folder_inbox_' || md5("id"), "id", NULL, 'Inbox', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization";
ALTER TABLE "Project" ADD COLUMN "folderId" TEXT;
UPDATE "Project" AS p
SET "folderId" = f."id"
FROM "Folder" AS f
WHERE f."organizationId" = p."organizationId"
AND f."parentId" IS NULL
AND f."name" = 'Inbox'
AND p."folderId" IS NULL;
ALTER TABLE "Project"
ADD CONSTRAINT "Project_folderId_fkey"
FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "Project_folderId_archivedAt_idx"
ON "Project"("folderId", "archivedAt");
ALTER TABLE "ProjectGroupBinding" ADD COLUMN "archivedAt" TIMESTAMP(3);
DROP INDEX IF EXISTS "ProjectGroupBinding_projectId_key";
DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_key";
DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_idx";
CREATE INDEX "ProjectGroupBinding_projectId_archivedAt_idx"
ON "ProjectGroupBinding"("projectId", "archivedAt");
CREATE INDEX "ProjectGroupBinding_chatId_archivedAt_idx"
ON "ProjectGroupBinding"("chatId", "archivedAt");
CREATE UNIQUE INDEX "ProjectGroupBinding_active_project_key"
ON "ProjectGroupBinding"("projectId")
WHERE "archivedAt" IS NULL;
CREATE UNIQUE INDEX "ProjectGroupBinding_active_chat_key"
ON "ProjectGroupBinding"("chatId")
WHERE "archivedAt" IS NULL;
+55 -17
View File
@@ -36,6 +36,8 @@ model Organization {
updatedAt DateTime @updatedAt
memberships OrganizationMembership[]
projectSettings OrganizationProjectSettings?
folders Folder[]
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
@@ -73,6 +75,17 @@ enum OrganizationMemberRole {
MEMBER
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
organizationId String @id
membersCanCreateProjects Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User {
id String @id @default(cuid())
@@ -222,9 +235,32 @@ model ExternalPrincipalMembership {
// --- Project & Feishu binding (ADR-0001) ---------------------------------
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources.
model Folder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
sortKey String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict)
children Folder[] @relation("folderTree")
projects Project[]
@@index([organizationId, parentId, archivedAt])
@@index([organizationId, parentId, sortKey])
}
model Project {
id String @id @default(cuid())
organizationId String
folderId String?
name String
workspaceDir String
createdByUserId String?
@@ -232,37 +268,39 @@ model Project {
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBindings ProjectGroupBinding[]
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt])
@@index([folderId, archivedAt])
@@index([archivedAt])
}
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
/// The "at most one binding per project" half is enforced by the 1:1 relation.
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
/// here; archival is a future policy, not a current column.
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat.
model ProjectGroupBinding {
id String @id @default(cuid())
projectId String @unique
chatId String @unique
id String @id @default(cuid())
projectId String
chatId String
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([chatId])
@@index([projectId, archivedAt])
@@index([chatId, archivedAt])
}
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
+4 -4
View File
@@ -535,8 +535,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id");
return;
}
const binding = await deps.prisma.projectGroupBinding.findUnique({
where: { chatId },
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (binding === null) {
@@ -618,8 +618,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (chatId === "") return;
// ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
const binding = await deps.prisma.projectGroupBinding.findUnique({
where: { chatId },
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (binding === null) {
+2 -2
View File
@@ -234,8 +234,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
return project.organizationId;
}
case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findUnique({
where: { chatId: resource.id },
const binding = await this.prisma.projectGroupBinding.findFirst({
where: { chatId: resource.id, archivedAt: null },
select: { project: { select: { organizationId: true } } },
});
if (binding === null) {
+537
View File
@@ -0,0 +1,537 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
import { createPermissionAuthorizer } from "./permission.js";
import { writeAudit } from "./audit.js";
export interface OrganizationProjectPolicy {
readonly organizationId: string;
readonly membersCanCreateProjects: boolean;
}
export interface ProjectOnboardingResult {
readonly projectId: string;
readonly organizationId: string;
readonly folderId: string | null;
readonly workspaceDir: string;
readonly chatId?: string | undefined;
}
export interface CreateOrgAdminProjectInput {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
}
export interface CreateFeishuChatProjectInput {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly chatId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
}
export interface BindFeishuChatToProjectInput {
readonly projectId: string;
readonly actorFeishuOpenId: string;
readonly chatId: string;
}
export interface ArchiveFeishuChatBindingInput {
readonly projectId?: string | undefined;
readonly chatId?: string | undefined;
readonly actorFeishuOpenId: string;
}
export interface CreateFolderInput {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
readonly sortKey?: string | undefined;
}
export interface MoveProjectToFolderInput {
readonly projectId: string;
readonly folderId: string | null;
}
export async function ensureOrganizationProjectSettings(
prisma: PrismaClient,
organizationId: string,
): Promise<OrganizationProjectPolicy> {
return prisma.$transaction(async (tx) => ensureOrganizationProjectSettingsTx(tx, organizationId));
}
export async function setMembersCanCreateProjects(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly enabled: boolean },
): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExists(prisma, input.organizationId);
const settings = await prisma.organizationProjectSettings.upsert({
where: { organizationId: input.organizationId },
update: { membersCanCreateProjects: input.enabled },
create: {
organizationId: input.organizationId,
membersCanCreateProjects: input.enabled,
},
select: { organizationId: true, membersCanCreateProjects: true },
});
return settings;
}
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
const name = requireNonEmpty(input.name, "folder name");
return prisma.$transaction(async (tx) => {
await ensureOrganizationExistsTx(tx, input.organizationId);
if (input.parentId !== undefined) {
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
}
return tx.folder.create({
data: {
organizationId: input.organizationId,
name,
sortKey: input.sortKey ?? "",
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
});
});
}
export async function moveProjectToFolder(
prisma: PrismaClient,
input: MoveProjectToFolderInput,
): Promise<{ readonly projectId: string; readonly folderId: string | null }> {
return prisma.$transaction(async (tx) => {
const project = await tx.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true },
});
if (project === null) throw new Error(`project not found: ${input.projectId}`);
if (input.folderId !== null) {
await assertFolderInOrganization(tx, input.folderId, project.organizationId);
}
const updated = await tx.project.update({
where: { id: input.projectId },
data: { folderId: input.folderId },
select: { id: true, folderId: true },
});
return { projectId: updated.id, folderId: updated.folderId };
});
}
export async function createProjectFromOrgAdmin(
prisma: PrismaClient,
input: CreateOrgAdminProjectInput,
): Promise<ProjectOnboardingResult> {
const name = requireNonEmpty(input.name, "project name");
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
if (!isOrgAdminRole(actor.role)) {
throw new Error(`org admin project creation requires OWNER or ADMIN in organization ${input.organizationId}`);
}
return createManagedProject(prisma, {
organizationId: input.organizationId,
actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId,
name,
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
chatId: undefined,
});
}
export async function createProjectFromFeishuChat(
prisma: PrismaClient,
input: CreateFeishuChatProjectInput,
): Promise<ProjectOnboardingResult> {
const chatId = requireNonEmpty(input.chatId, "chat id");
const name = requireNonEmpty(input.name, "project name");
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
const settings = await ensureOrganizationProjectSettings(prisma, input.organizationId);
if (!settings.membersCanCreateProjects && !isOrgAdminRole(actor.role)) {
throw new Error(`members cannot create projects in organization ${input.organizationId}`);
}
const activeBinding = await prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeBinding !== null) {
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeBinding.projectId}`);
}
return createManagedProject(prisma, {
organizationId: input.organizationId,
actorUserId: actor.userId,
actorFeishuOpenId: input.actorFeishuOpenId,
name,
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
chatId,
});
}
export async function bindFeishuChatToProject(
prisma: PrismaClient,
input: BindFeishuChatToProjectInput,
): Promise<ProjectOnboardingResult> {
const chatId = requireNonEmpty(input.chatId, "chat id");
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
});
if (project === null) throw new Error(`project not found: ${input.projectId}`);
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => {
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null },
select: { chatId: true },
});
if (activeProjectBinding !== null) {
throw new Error(`project ${project.id} is already bound to Feishu chat ${activeProjectBinding.chatId}`);
}
const activeChatBinding = await tx.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeChatBinding !== null) {
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`);
}
await tx.projectGroupBinding.create({
data: { projectId: project.id, chatId, createdByUserId: actor.userId },
});
await replaceProjectGrant(tx, {
projectId: project.id,
principalType: "FEISHU_CHAT",
principalId: chatId,
role: "EDIT",
createdByUserId: actor.userId,
});
});
await writeAudit(prisma, {
projectId: project.id,
actorUserId: actor.userId,
action: "project.chat_bound",
metadata: { chatId, actorVia: actor.via },
});
return {
projectId: project.id,
organizationId: project.organizationId,
folderId: project.folderId,
workspaceDir: project.workspaceDir,
chatId,
};
}
export async function archiveFeishuChatBinding(
prisma: PrismaClient,
input: ArchiveFeishuChatBindingInput,
): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined; readonly chatId?: string | undefined }> {
if ((input.projectId === undefined || input.projectId === "") && (input.chatId === undefined || input.chatId === "")) {
throw new Error("archiveFeishuChatBinding requires projectId or chatId");
}
if (input.projectId !== undefined && input.projectId !== "" && input.chatId !== undefined && input.chatId !== "") {
throw new Error("archiveFeishuChatBinding accepts only one of projectId or chatId");
}
const binding = await prisma.projectGroupBinding.findFirst({
where: {
archivedAt: null,
...(input.projectId !== undefined && input.projectId !== "" ? { projectId: input.projectId } : { chatId: input.chatId! }),
},
select: { id: true, projectId: true, chatId: true, project: { select: { organizationId: true } } },
});
if (binding === null) return { archived: false };
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => {
await tx.projectGroupBinding.update({
where: { id: binding.id },
data: { archivedAt: new Date() },
});
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: binding.projectId,
principalType: "FEISHU_CHAT",
principalId: binding.chatId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
});
await writeAudit(prisma, {
projectId: binding.projectId,
actorUserId: actor.userId,
action: "project.chat_binding_archived",
metadata: { chatId: binding.chatId, actorVia: actor.via },
});
return { archived: true, projectId: binding.projectId, chatId: binding.chatId };
}
async function createManagedProject(
prisma: PrismaClient,
input: {
readonly organizationId: string;
readonly actorUserId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
readonly workspaceRoot: string;
readonly folderId: string | undefined;
readonly sortKey?: string | undefined;
readonly chatId: string | undefined;
},
): Promise<ProjectOnboardingResult> {
const organization = await prisma.organization.findUnique({
where: { id: input.organizationId },
select: { id: true, slug: true },
});
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
const projectId = createProjectId();
const workspaceDir = projectWorkspaceDir({
workspaceRoot: input.workspaceRoot,
organizationSlug: organization.slug,
projectId,
});
await mkdir(workspaceDir, { recursive: true });
const project = await prisma.$transaction(async (tx) => {
await ensureOrganizationProjectSettingsTx(tx, organization.id);
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
await assertFolderInOrganization(tx, folderId, organization.id);
const created = await tx.project.create({
data: {
id: projectId,
organizationId: organization.id,
folderId,
name: input.name,
workspaceDir,
createdByUserId: input.actorUserId,
},
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
});
await tx.permissionSettings.create({
data: defaultProjectPermissionSettings(created.id),
});
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "USER",
principalId: input.actorFeishuOpenId,
role: "MANAGE",
createdByUserId: input.actorUserId,
});
if (input.chatId !== undefined) {
await tx.projectGroupBinding.create({
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
});
await replaceProjectGrant(tx, {
projectId: created.id,
principalType: "FEISHU_CHAT",
principalId: input.chatId,
role: "EDIT",
createdByUserId: input.actorUserId,
});
}
return created;
});
await writeAudit(prisma, {
projectId: project.id,
actorUserId: input.actorUserId,
action: input.chatId === undefined ? "project.created" : "project.created_from_feishu_chat",
metadata: { folderId: project.folderId, workspaceDir, chatId: input.chatId ?? null },
});
return {
projectId: project.id,
organizationId: project.organizationId,
folderId: project.folderId,
workspaceDir: project.workspaceDir,
...(input.chatId !== undefined ? { chatId: input.chatId } : {}),
};
}
async function requireProjectManager(
prisma: PrismaClient,
projectId: string,
organizationId: string,
actorFeishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly via: "org-admin" | "project-manage" }> {
const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId);
if (isOrgAdminRole(actor.role)) {
return { ...actor, via: "org-admin" };
}
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: actorFeishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!decision.allowed) {
throw new Error(`project ${projectId} requires MANAGE for ${actorFeishuOpenId}: ${decision.reason}`);
}
return { ...actor, via: "project-manage" };
}
async function requireActiveOrgMember(
prisma: PrismaClient,
organizationId: string,
feishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
const user = await prisma.user.findUnique({
where: { feishuOpenId },
select: {
id: true,
organizationMemberships: {
where: { organizationId, revokedAt: null },
select: { role: true },
take: 1,
},
},
});
if (user === null) {
throw new Error(`Feishu user ${feishuOpenId} must log in before project onboarding`);
}
const membership = user.organizationMemberships[0];
if (membership === undefined) {
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
}
return { userId: user.id, role: membership.role };
}
async function ensureOrganizationProjectSettingsTx(
prisma: Prisma.TransactionClient,
organizationId: string,
): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExistsTx(prisma, organizationId);
return prisma.organizationProjectSettings.upsert({
where: { organizationId },
update: {},
create: { organizationId, membersCanCreateProjects: true },
select: { organizationId: true, membersCanCreateProjects: true },
});
}
async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> {
const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: null, name: "Inbox", archivedAt: null },
select: { id: true },
});
if (existing !== null) return existing;
return prisma.folder.create({
data: { organizationId, name: "Inbox", sortKey: "000000" },
select: { id: true },
});
}
async function assertFolderInOrganization(
prisma: Prisma.TransactionClient,
folderId: string,
organizationId: string,
): Promise<void> {
const folder = await prisma.folder.findUnique({
where: { id: folderId },
select: { organizationId: true, archivedAt: true },
});
if (folder === null || folder.archivedAt !== null) {
throw new Error(`active folder not found: ${folderId}`);
}
if (folder.organizationId !== organizationId) {
throw new Error(`folder ${folderId} is in ${folder.organizationId}, not organization ${organizationId}`);
}
}
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
}
async function ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
}
async function replaceProjectGrant(
prisma: Prisma.TransactionClient,
input: {
readonly projectId: string;
readonly principalType: "USER" | "FEISHU_CHAT";
readonly principalId: string;
readonly role: PermissionRole;
readonly createdByUserId: string;
},
): Promise<void> {
await prisma.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: input.projectId,
principalType: input.principalType,
principalId: input.principalId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: input.projectId,
principalType: input.principalType,
principalId: input.principalId,
role: input.role,
createdByUserId: input.createdByUserId,
},
});
}
function defaultProjectPermissionSettings(projectId: string): Prisma.PermissionSettingsCreateInput {
return {
resourceType: "PROJECT",
resourceId: projectId,
externalShare: "DISABLED",
comment: "ROLE",
copyDownload: "ROLE",
collaboratorMgmt: "MANAGE_ONLY",
agentTrigger: "ROLE",
agentCancel: "MANAGE_ONLY",
};
}
function isOrgAdminRole(role: OrganizationMemberRole): boolean {
return role === "OWNER" || role === "ADMIN";
}
function requireNonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} is required`);
return trimmed;
}
function createProjectId(): string {
return `project_${randomUUID().replaceAll("-", "")}`;
}
function projectWorkspaceDir(input: {
readonly workspaceRoot: string;
readonly organizationSlug: string;
readonly projectId: string;
}): string {
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id"));
const rel = relative(root, dir);
if (rel === "" || rel.startsWith("..")) {
throw new Error(`allocated workspace escapes root: ${dir}`);
}
return dir;
}
function safePathSegment(value: string, label: string): string {
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
if (segment === "." || segment === ".." || segment === "") {
throw new Error(`${label} is not a safe path segment`);
}
return segment;
}
+21
View File
@@ -44,6 +44,8 @@ export async function resetDb(): Promise<void> {
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
@@ -68,6 +70,25 @@ export async function seedTestOrganization(
name: "Test Default Organization",
},
});
await prisma.organizationProjectSettings.upsert({
where: { organizationId: id },
update: {},
create: { organizationId: id, membersCanCreateProjects: true },
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await prisma.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
sortKey: "000000",
},
});
}
}
/** A logger that discards everything (tests don't need fastify's pino). */
@@ -0,0 +1,178 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
import {
archiveFeishuChatBinding,
bindFeishuChatToProject,
createFolder,
createProjectFromFeishuChat,
createProjectFromOrgAdmin,
setMembersCanCreateProjects,
} from "../../src/projectOnboarding.js";
const workspaceRoots: string[] = [];
describe("ADR-0021 project onboarding", () => {
beforeEach(async () => {
await resetDb();
});
afterEach(async () => {
while (workspaceRoots.length > 0) {
const root = workspaceRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => {
await prisma.$disconnect();
});
it("lets an org admin create an unbound project in a folder", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
const folder = await createFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
name: "Grade 7",
sortKey: "010000",
});
const workspaceRoot = await tempWorkspaceRoot();
const result = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_admin",
name: "Newton Lesson",
folderId: folder.id,
workspaceRoot,
});
expect(result.folderId).toBe(folder.id);
expect(result.chatId).toBeUndefined();
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true);
const grant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: result.projectId,
principalType: "USER",
principalId: "ou_admin",
revokedAt: null,
},
select: { role: true },
});
expect(grant?.role).toBe("MANAGE");
const settings = await prisma.permissionSettings.findUnique({
where: { resourceType_resourceId: { resourceType: "PROJECT", resourceId: result.projectId } },
});
expect(settings?.agentTrigger).toBe("ROLE");
});
it("lets an org member create and bind a project from an unbound Feishu chat", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot();
const result = await createProjectFromFeishuChat(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
chatId: "chat-onboard",
name: "Poetry Workshop",
workspaceRoot,
});
expect(result.chatId).toBe("chat-onboard");
const binding = await prisma.projectGroupBinding.findFirst({
where: { projectId: result.projectId, chatId: "chat-onboard", archivedAt: null },
});
expect(binding).not.toBeNull();
const grants = await prisma.permissionGrant.findMany({
where: { resourceType: "PROJECT", resourceId: result.projectId, revokedAt: null },
select: { principalType: true, principalId: true, role: true },
orderBy: { principalType: "asc" },
});
expect(grants).toEqual(expect.arrayContaining([
{ principalType: "USER", principalId: "ou_member", role: "MANAGE" },
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard", role: "EDIT" },
]));
});
it("blocks ordinary Feishu project creation when the org setting is off", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
await setMembersCanCreateProjects(prisma, {
organizationId: DEFAULT_ORG_ID,
enabled: false,
});
await expect(createProjectFromFeishuChat(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
chatId: "chat-denied",
name: "Denied Project",
workspaceRoot: await tempWorkspaceRoot(),
})).rejects.toThrow(/members cannot create projects/);
});
it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
await seedUser("u-owner", "ou_owner", "OWNER");
const workspaceRoot = await tempWorkspaceRoot();
const project = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
name: "Reusable Project",
workspaceRoot,
});
await bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-first",
});
await expect(bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-second",
})).rejects.toThrow(/already bound/);
await expect(archiveFeishuChatBinding(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
})).resolves.toMatchObject({ archived: true, chatId: "chat-first" });
await bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: "ou_owner",
chatId: "chat-second",
});
const activeBindings = await prisma.projectGroupBinding.findMany({
where: { projectId: project.projectId, archivedAt: null },
select: { chatId: true },
});
expect(activeBindings).toEqual([{ chatId: "chat-second" }]);
const oldChatGrant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: project.projectId,
principalType: "FEISHU_CHAT",
principalId: "chat-first",
revokedAt: null,
},
});
expect(oldChatGrant).toBeNull();
});
});
async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
await prisma.user.create({
data: {
id,
feishuOpenId,
displayName: feishuOpenId,
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
},
});
}
async function tempWorkspaceRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-onboarding-"));
workspaceRoots.push(root);
return root;
}
+1 -1
View File
@@ -255,7 +255,7 @@ function mockPrisma(): PrismaClient {
create: vi.fn(async () => ({ id: "receipt-1" })),
},
projectGroupBinding: {
findUnique: vi.fn(async () => ({ projectId: "project-1" })),
findFirst: vi.fn(async () => ({ projectId: "project-1" })),
},
project: {
findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })),