forked from bai/curriculum-project-hub
fix: move folder creation into project move flow
This commit is contained in:
@@ -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(
|
export async function loadProjectConsole(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
input: { readonly projectId: string; readonly chatId: string },
|
input: { readonly projectId: string; readonly chatId: string },
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export function buildProjectManagementCard(params: {
|
|||||||
readonly sdkSessionReady: boolean;
|
readonly sdkSessionReady: boolean;
|
||||||
} | null | undefined;
|
} | null | undefined;
|
||||||
readonly canManageProject?: boolean | undefined;
|
readonly canManageProject?: boolean | undefined;
|
||||||
readonly canCreateFolder?: boolean | undefined;
|
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const elements: unknown[] = [{
|
const elements: unknown[] = [{
|
||||||
tag: "markdown",
|
tag: "markdown",
|
||||||
@@ -149,7 +148,6 @@ export function buildProjectManagementCard(params: {
|
|||||||
renameProjectForm(params),
|
renameProjectForm(params),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (params.canCreateFolder === true) elements.push(createFolderForm(params));
|
|
||||||
return {
|
return {
|
||||||
config: { wide_screen_mode: true },
|
config: { wide_screen_mode: true },
|
||||||
header: {
|
header: {
|
||||||
@@ -202,6 +200,7 @@ export function buildMoveProjectCard(params: {
|
|||||||
readonly parentFolderId: string | null;
|
readonly parentFolderId: string | null;
|
||||||
readonly breadcrumb: string;
|
readonly breadcrumb: string;
|
||||||
readonly childFolders: readonly { readonly id: string; readonly name: string }[];
|
readonly childFolders: readonly { readonly id: string; readonly name: string }[];
|
||||||
|
readonly canCreateFolder: boolean;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const navigation: unknown[] = [];
|
const navigation: unknown[] = [];
|
||||||
if (params.folderId !== null) {
|
if (params.folderId !== null) {
|
||||||
@@ -232,6 +231,13 @@ export function buildMoveProjectCard(params: {
|
|||||||
project_id: params.projectId,
|
project_id: params.projectId,
|
||||||
...(params.folderId !== null ? { folder_id: params.folderId } : {}),
|
...(params.folderId !== null ? { folder_id: params.folderId } : {}),
|
||||||
}, "primary")] });
|
}, "primary")] });
|
||||||
|
if (params.canCreateFolder) {
|
||||||
|
elements.push(createFolderAndMoveForm({
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
projectId: params.projectId,
|
||||||
|
parentFolderId: params.folderId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
config: { wide_screen_mode: true },
|
config: { wide_screen_mode: true },
|
||||||
header: { title: { tag: "plain_text", content: "移动项目" }, template: "blue" },
|
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 {
|
return {
|
||||||
tag: "form",
|
tag: "form",
|
||||||
name: "folder_create_form",
|
name: "folder_create_form",
|
||||||
@@ -443,12 +453,12 @@ function createFolderForm(params: { readonly organizationId: string; readonly pr
|
|||||||
name: "folder_name",
|
name: "folder_name",
|
||||||
required: true,
|
required: true,
|
||||||
max_length: 100,
|
max_length: 100,
|
||||||
placeholder: { tag: "plain_text", content: "在当前目录下新建 Folder" },
|
placeholder: { tag: "plain_text", content: "在当前目标目录下新建 Folder" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "button",
|
tag: "button",
|
||||||
name: "folder_create_submit",
|
name: "folder_create_submit",
|
||||||
text: { tag: "plain_text", content: "新建目录" },
|
text: { tag: "plain_text", content: "新建并移动" },
|
||||||
type: "default",
|
type: "default",
|
||||||
complex_interaction: true,
|
complex_interaction: true,
|
||||||
action_type: "form_submit",
|
action_type: "form_submit",
|
||||||
@@ -456,6 +466,7 @@ function createFolderForm(params: { readonly organizationId: string; readonly pr
|
|||||||
action: "create_folder",
|
action: "create_folder",
|
||||||
organization_id: params.organizationId,
|
organization_id: params.organizationId,
|
||||||
project_id: params.projectId,
|
project_id: params.projectId,
|
||||||
|
...(params.parentFolderId !== null ? { folder_id: params.parentFolderId } : {}),
|
||||||
} },
|
} },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+21
-10
@@ -56,7 +56,6 @@ import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommand
|
|||||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
|
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
|
||||||
import {
|
import {
|
||||||
bindFeishuChatToProject,
|
bindFeishuChatToProject,
|
||||||
createFolder,
|
|
||||||
createProjectFromFeishuChat,
|
createProjectFromFeishuChat,
|
||||||
ensureOrganizationProjectSettings,
|
ensureOrganizationProjectSettings,
|
||||||
moveProjectToFolder,
|
moveProjectToFolder,
|
||||||
@@ -75,6 +74,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
archiveCurrentRoleSession,
|
archiveCurrentRoleSession,
|
||||||
browseFolderDestinations,
|
browseFolderDestinations,
|
||||||
|
createFolderAndMoveProject,
|
||||||
listRoleSessionHistory,
|
listRoleSessionHistory,
|
||||||
loadProjectConsole,
|
loadProjectConsole,
|
||||||
resumeRoleSession,
|
resumeRoleSession,
|
||||||
@@ -221,7 +221,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
roles: visibleRoles,
|
roles: visibleRoles,
|
||||||
currentSession: state.currentSession,
|
currentSession: state.currentSession,
|
||||||
canManageProject: manageDecision.allowed || isOrgAdmin,
|
canManageProject: manageDecision.allowed || isOrgAdmin,
|
||||||
canCreateFolder: isOrgAdmin,
|
|
||||||
...(title !== undefined ? { title } : {}),
|
...(title !== undefined ? { title } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -949,6 +948,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
organizationId: state.organizationId,
|
organizationId: state.organizationId,
|
||||||
projectId,
|
projectId,
|
||||||
projectName: state.projectName,
|
projectName: state.projectName,
|
||||||
|
canCreateFolder: isOrgAdmin,
|
||||||
...page,
|
...page,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
@@ -968,18 +968,20 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN");
|
if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN");
|
||||||
const folderName = event.action.form_value?.["folder_name"];
|
const folderName = event.action.form_value?.["folder_name"];
|
||||||
if (typeof folderName !== "string") throw new Error("create folder form is missing 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,
|
organizationId: state.organizationId,
|
||||||
name: folderName.slice(0, 100),
|
|
||||||
...(state.folderId !== null ? { parentId: state.folderId } : {}),
|
|
||||||
});
|
|
||||||
await writeAudit(deps.prisma, {
|
|
||||||
projectId,
|
projectId,
|
||||||
action: "folder.created_from_feishu",
|
parentFolderId: action.folder_id ?? null,
|
||||||
metadata: { folderId: folder.id, parentId: state.folderId, name: folder.name },
|
name: folderName.slice(0, 100),
|
||||||
|
...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
|
||||||
});
|
});
|
||||||
if (messageId === undefined) throw new Error("folder creation requires message id");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (action.action === "rename_project") {
|
if (action.action === "rename_project") {
|
||||||
@@ -1359,6 +1361,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = messageBatchKey(chatId, senderOpenId, runContext.roleId);
|
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)) {
|
if (!batchContexts.has(key)) {
|
||||||
batchContexts.set(key, runContext);
|
batchContexts.set(key, runContext);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,52 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新");
|
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 () => {
|
it("binds an existing manageable project from the unbound-chat onboarding card", async () => {
|
||||||
await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER");
|
await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER");
|
||||||
await prisma.project.create({
|
await prisma.project.create({
|
||||||
@@ -660,11 +706,11 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
await vi.waitFor(async () => {
|
await vi.waitFor(async () => {
|
||||||
expect(await prisma.agentRun.count({ where: { projectId: "proj-batch-role", status: "COMPLETED" } })).toBe(2);
|
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" } });
|
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: "draft" });
|
||||||
expect(runs.find((run) => run.prompt.includes("审校消息"))?.metadata).toMatchObject({ roleId: "review" });
|
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 () => {
|
it("keeps the project session shared while labeling each sender in the prompt", async () => {
|
||||||
await seedProject("proj-speaker", "chat-speaker");
|
await seedProject("proj-speaker", "chat-speaker");
|
||||||
|
|||||||
Reference in New Issue
Block a user