From 2211beb42cd030d07e7c66455c49f9727d2c5269 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Mon, 13 Jul 2026 17:05:45 +0800 Subject: [PATCH] fix: move folder creation into project move flow --- hub/src/feishu/projectConsole.ts | 59 +++++++++++++++++++++++++ hub/src/feishu/projectOnboardingCard.ts | 21 ++++++--- hub/src/feishu/trigger.ts | 31 ++++++++----- hub/test/integration/trigger.test.ts | 50 ++++++++++++++++++++- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/hub/src/feishu/projectConsole.ts b/hub/src/feishu/projectConsole.ts index e9a8133..7b27c5b 100644 --- a/hub/src/feishu/projectConsole.ts +++ b/hub/src/feishu/projectConsole.ts @@ -52,6 +52,65 @@ export async function browseFolderDestinations( }; } +export async function createFolderAndMoveProject( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly projectId: string; + readonly parentFolderId: string | null; + readonly name: string; + readonly actorUserId?: string | undefined; + }, +): Promise<{ readonly folderId: string; readonly folderName: string }> { + const name = input.name.trim(); + if (name === "") throw new Error("folder name is required"); + if (name.length > 100) throw new Error("folder name must not exceed 100 characters"); + return prisma.$transaction(async (tx) => { + await lockActiveProjectOrganization(tx, input.projectId); + const project = await tx.project.findFirst({ + where: { id: input.projectId, organizationId: input.organizationId, archivedAt: null }, + select: { id: true, folderId: true }, + }); + if (project === null) throw new Error("active project not found in Organization"); + if (input.parentFolderId !== null) { + const parent = await tx.folder.findFirst({ + where: { + id: input.parentFolderId, + organizationId: input.organizationId, + archivedAt: null, + kind: { not: "SYSTEM_INBOX" }, + }, + select: { id: true }, + }); + if (parent === null) throw new Error("active destination folder not found in Organization"); + } + const folder = await tx.folder.create({ + data: { + organizationId: input.organizationId, + parentId: input.parentFolderId, + name, + }, + select: { id: true, name: true }, + }); + await tx.project.update({ where: { id: project.id }, data: { folderId: folder.id } }); + await tx.auditEntry.create({ + data: { + organizationId: input.organizationId, + projectId: project.id, + ...(input.actorUserId !== undefined ? { actorUserId: input.actorUserId } : {}), + action: "folder.created_and_project_moved_from_feishu", + metadata: { + folderId: folder.id, + parentFolderId: input.parentFolderId, + previousFolderId: project.folderId, + name: folder.name, + }, + }, + }); + return { folderId: folder.id, folderName: folder.name }; + }); +} + export async function loadProjectConsole( prisma: PrismaClient, input: { readonly projectId: string; readonly chatId: string }, diff --git a/hub/src/feishu/projectOnboardingCard.ts b/hub/src/feishu/projectOnboardingCard.ts index bc09df2..24decf4 100644 --- a/hub/src/feishu/projectOnboardingCard.ts +++ b/hub/src/feishu/projectOnboardingCard.ts @@ -88,7 +88,6 @@ export function buildProjectManagementCard(params: { readonly sdkSessionReady: boolean; } | null | undefined; readonly canManageProject?: boolean | undefined; - readonly canCreateFolder?: boolean | undefined; }): Record { const elements: unknown[] = [{ tag: "markdown", @@ -149,7 +148,6 @@ export function buildProjectManagementCard(params: { renameProjectForm(params), ); } - if (params.canCreateFolder === true) elements.push(createFolderForm(params)); return { config: { wide_screen_mode: true }, header: { @@ -202,6 +200,7 @@ export function buildMoveProjectCard(params: { readonly parentFolderId: string | null; readonly breadcrumb: string; readonly childFolders: readonly { readonly id: string; readonly name: string }[]; + readonly canCreateFolder: boolean; }): Record { const navigation: unknown[] = []; if (params.folderId !== null) { @@ -232,6 +231,13 @@ export function buildMoveProjectCard(params: { project_id: params.projectId, ...(params.folderId !== null ? { folder_id: params.folderId } : {}), }, "primary")] }); + if (params.canCreateFolder) { + elements.push(createFolderAndMoveForm({ + organizationId: params.organizationId, + projectId: params.projectId, + parentFolderId: params.folderId, + })); + } return { config: { wide_screen_mode: true }, header: { title: { tag: "plain_text", content: "移动项目" }, template: "blue" }, @@ -433,7 +439,11 @@ function renameProjectForm(params: { }; } -function createFolderForm(params: { readonly organizationId: string; readonly projectId: string }): unknown { +function createFolderAndMoveForm(params: { + readonly organizationId: string; + readonly projectId: string; + readonly parentFolderId: string | null; +}): unknown { return { tag: "form", name: "folder_create_form", @@ -443,12 +453,12 @@ function createFolderForm(params: { readonly organizationId: string; readonly pr name: "folder_name", required: true, max_length: 100, - placeholder: { tag: "plain_text", content: "在当前目录下新建 Folder" }, + placeholder: { tag: "plain_text", content: "在当前目标目录下新建 Folder" }, }, { tag: "button", name: "folder_create_submit", - text: { tag: "plain_text", content: "新建目录" }, + text: { tag: "plain_text", content: "新建并移动" }, type: "default", complex_interaction: true, action_type: "form_submit", @@ -456,6 +466,7 @@ function createFolderForm(params: { readonly organizationId: string; readonly pr action: "create_folder", organization_id: params.organizationId, project_id: params.projectId, + ...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}), } }, }, ], diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 990e8df..16fe5ee 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -56,7 +56,6 @@ import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommand import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; import { bindFeishuChatToProject, - createFolder, createProjectFromFeishuChat, ensureOrganizationProjectSettings, moveProjectToFolder, @@ -75,6 +74,7 @@ import { import { archiveCurrentRoleSession, browseFolderDestinations, + createFolderAndMoveProject, listRoleSessionHistory, loadProjectConsole, resumeRoleSession, @@ -221,7 +221,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { roles: visibleRoles, currentSession: state.currentSession, canManageProject: manageDecision.allowed || isOrgAdmin, - canCreateFolder: isOrgAdmin, ...(title !== undefined ? { title } : {}), }); } @@ -949,6 +948,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { organizationId: state.organizationId, projectId, projectName: state.projectName, + canCreateFolder: isOrgAdmin, ...page, })); return; @@ -968,18 +968,20 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN"); const folderName = event.action.form_value?.["folder_name"]; if (typeof folderName !== "string") throw new Error("create folder form is missing folder_name"); - const folder = await createFolder(deps.prisma, { + const folder = await createFolderAndMoveProject(deps.prisma, { organizationId: state.organizationId, - name: folderName.slice(0, 100), - ...(state.folderId !== null ? { parentId: state.folderId } : {}), - }); - await writeAudit(deps.prisma, { projectId, - action: "folder.created_from_feishu", - metadata: { folderId: folder.id, parentId: state.folderId, name: folder.name }, + parentFolderId: action.folder_id ?? null, + name: folderName.slice(0, 100), + ...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}), }); if (messageId === undefined) throw new Error("folder creation requires message id"); - await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "目录已创建")); + await patchCard(rt, messageId, await projectConsoleCard( + projectId, + chatId, + operatorOpenId, + `已新建目录“${folder.folderName}”并移动项目`, + )); return; } if (action.action === "rename_project") { @@ -1359,6 +1361,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return; } const key = messageBatchKey(chatId, senderOpenId, runContext.roleId); + for (const [pendingKey, pendingContext] of batchContexts) { + if ( + pendingKey !== key && + pendingContext.chatId === chatId && + pendingContext.senderOpenId === senderOpenId + ) { + await messageBatcher.flushNow(pendingKey); + } + } if (!batchContexts.has(key)) { batchContexts.set(key, runContext); } diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 410b2cb..e303f7c 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -325,6 +325,52 @@ describe("trigger full lifecycle (integration)", () => { expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新"); }); + it("offers folder creation only inside move flow and atomically moves into the new folder", async () => { + await seedProject("project-folder-flow", "chat-folder-flow", { role: "MANAGE" }); + await prisma.organizationMembership.updateMany({ + where: { organizationId: DEFAULT_ORG_ID, userId: "u_project-folder-flow", revokedAt: null }, + data: { role: "ADMIN" }, + }); + const parent = await prisma.folder.create({ + data: { organizationId: DEFAULT_ORG_ID, name: "课程目录" }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); + + await trigger(makeEvent("chat-folder-flow", "@_user_1 /project"), rt); + expect(JSON.stringify(rt.sentCards.at(-1))).not.toContain("folder_create_form"); + + await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", { + project_onboarding: { + action: "browse_move_destination", + organization_id: DEFAULT_ORG_ID, + project_id: "project-folder-flow", + folder_id: parent.id, + }, + }, "ou_test_user"), rt); + const moveCard = JSON.stringify(rt.sentPatches.at(-1)); + expect(moveCard).toContain("folder_create_form"); + expect(moveCard).toContain("新建并移动"); + + await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", { + project_onboarding: { + action: "create_folder", + organization_id: DEFAULT_ORG_ID, + project_id: "project-folder-flow", + folder_id: parent.id, + }, + }, "ou_test_user", { folder_name: "力学单元" }), rt); + + const folder = await prisma.folder.findFirstOrThrow({ + where: { organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "力学单元" }, + }); + await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-folder-flow" } })) + .resolves.toMatchObject({ folderId: folder.id }); + await expect(prisma.auditEntry.findFirstOrThrow({ + where: { projectId: "project-folder-flow", action: "folder.created_and_project_moved_from_feishu" }, + })).resolves.toMatchObject({ metadata: expect.objectContaining({ folderId: folder.id, parentFolderId: parent.id }) }); + expect(cardHeaderTitle(rt.sentPatches.at(-1))).toContain("已新建目录"); + }); + it("binds an existing manageable project from the unbound-chat onboarding card", async () => { await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER"); await prisma.project.create({ @@ -660,11 +706,11 @@ describe("trigger full lifecycle (integration)", () => { await vi.waitFor(async () => { expect(await prisma.agentRun.count({ where: { projectId: "proj-batch-role", status: "COMPLETED" } })).toBe(2); - }); + }, { timeout: 5_000 }); const runs = await prisma.agentRun.findMany({ where: { projectId: "proj-batch-role" } }); expect(runs.find((run) => run.prompt.includes("草稿消息"))?.metadata).toMatchObject({ roleId: "draft" }); expect(runs.find((run) => run.prompt.includes("审校消息"))?.metadata).toMatchObject({ roleId: "review" }); - }); + }, 10_000); it("keeps the project session shared while labeling each sender in the prompt", async () => { await seedProject("proj-speaker", "chat-speaker");