From 7e81cf5a47036b48e486a82bd95f8860a7e99bd7 Mon Sep 17 00:00:00 2001 From: jhuang Date: Fri, 13 Mar 2026 22:09:47 +0800 Subject: [PATCH 1/2] feat: allow Idea to be reused across multiple Proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove checkIdeasAvailability blocking in MCP tool (pm.ts), convert to informational warning - Remove availability check blocking in frontend (actions.ts, new/page.tsx) - Allow 'Create Proposal' button for ideas in elaborating, proposal_created, and completed states (idea-detail-panel.tsx) - Keep existing status transition logic (already idempotent for reuse) Closes: Idea 可重复用于生成 Proposal --- .../projects/[uuid]/ideas/idea-detail-panel.tsx | 5 ++--- .../projects/[uuid]/proposals/actions.ts | 14 +------------- .../projects/[uuid]/proposals/new/page.tsx | 11 ++--------- src/mcp/tools/pm.ts | 16 +++++++--------- 4 files changed, 12 insertions(+), 34 deletions(-) diff --git a/src/app/(dashboard)/projects/[uuid]/ideas/idea-detail-panel.tsx b/src/app/(dashboard)/projects/[uuid]/ideas/idea-detail-panel.tsx index b174a8a..e4166eb 100644 --- a/src/app/(dashboard)/projects/[uuid]/ideas/idea-detail-panel.tsx +++ b/src/app/(dashboard)/projects/[uuid]/ideas/idea-detail-panel.tsx @@ -228,9 +228,8 @@ export function IdeaDetailPanel({ const canAssign = idea.status !== "completed" && idea.status !== "closed"; const elaborationResolved = idea.elaborationStatus === "resolved"; const canCreateProposal = - idea.status === "elaborating" && - elaborationResolved && - !isUsedInProposal; + (idea.status === "elaborating" || idea.status === "proposal_created" || idea.status === "completed") && + elaborationResolved; const canSkipElaboration = idea.status === "elaborating" && (!idea.elaborationStatus || idea.elaborationStatus !== "resolved") && diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts b/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts index 893c0f2..8f11d7a 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts +++ b/src/app/(dashboard)/projects/[uuid]/proposals/actions.ts @@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache"; import { getServerAuthContext } from "@/lib/auth-server"; import { createProposal, - checkIdeasAvailability, checkIdeasAssignee, type DocumentDraftInput, type TaskDraftInput, @@ -58,18 +57,7 @@ export async function createProposalAction( }; } - // Validate whether these Ideas have already been used by another Proposal - const availabilityCheck = await checkIdeasAvailability( - auth.companyUuid, - data.inputUuids - ); - if (!availabilityCheck.available) { - const usedIdea = availabilityCheck.usedIdeas[0]; - return { - success: false, - error: `One of the selected ideas is already used in proposal "${usedIdea.proposalTitle}"`, - }; - } + // Note: Ideas can be reused across multiple Proposals (no availability check blocking) } const proposal = await createProposal({ diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx b/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx index ac86162..532cc9a 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx @@ -6,7 +6,6 @@ import { getTranslations } from "next-intl/server"; import { getServerAuthContext } from "@/lib/auth-server"; import { projectExists } from "@/services/project.service"; import { listIdeas } from "@/services/idea.service"; -import { checkIdeasAvailability } from "@/services/proposal.service"; import { CreateProposalForm } from "./create-proposal-form"; interface PageProps { @@ -41,14 +40,8 @@ export default async function NewProposalPage({ params, searchParams }: PageProp actorType: auth.type, }); - // Filter out ideas that have not been used yet - const ideaUuids = ideas.map(idea => idea.uuid); - const availabilityCheck = ideaUuids.length > 0 - ? await checkIdeasAvailability(auth.companyUuid, ideaUuids) - : { usedIdeas: [] }; - - const usedIdeaUuids = new Set(availabilityCheck.usedIdeas.map(u => u.uuid)); - const availableIdeas = ideas.filter(idea => !usedIdeaUuids.has(idea.uuid)); + // All ideas with resolved elaboration are available (ideas can be reused across proposals) + const availableIdeas = ideas; return (
diff --git a/src/mcp/tools/pm.ts b/src/mcp/tools/pm.ts index 130a8ae..d4981ab 100644 --- a/src/mcp/tools/pm.ts +++ b/src/mcp/tools/pm.ts @@ -184,7 +184,8 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { return { content: [{ type: "text", text: "Project not found" }], isError: true }; } - // If input type is idea, validate assignee and uniqueness + // If input type is idea, validate assignee + let reusedWarning = ""; if (inputType === "idea") { const assigneeCheck = await proposalService.checkIdeasAssignee( auth.companyUuid, @@ -199,17 +200,14 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }; } + // Check if ideas are already used by other proposals (informational only, not blocking) const availabilityCheck = await proposalService.checkIdeasAvailability( auth.companyUuid, inputUuids ); - if (!availabilityCheck.available) { - const usedIdea = availabilityCheck.usedIdeas[0]; - return { - content: [{ type: "text", text: `Idea is already used by Proposal "${usedIdea.proposalTitle}"` }], - isError: true, - }; - } + reusedWarning = !availabilityCheck.available + ? `\nNote: Idea is also referenced by existing Proposal(s): ${availabilityCheck.usedIdeas.map(u => `"${u.proposalTitle}"`).join(", ")}` + : ""; } const proposal = await proposalService.createProposal({ @@ -224,7 +222,7 @@ export function registerPmTools(server: McpServer, auth: AgentAuthContext) { }); return { - content: [{ type: "text", text: JSON.stringify({ uuid: proposal.uuid, title: proposal.title, status: proposal.status }, null, 2) }], + content: [{ type: "text", text: JSON.stringify({ uuid: proposal.uuid, title: proposal.title, status: proposal.status }, null, 2) + reusedWarning }], }; } ); From d2a3be88c1de22a4aea6e46bffe456a40461bf74 Mon Sep 17 00:00:00 2001 From: jhuang Date: Fri, 13 Mar 2026 22:20:46 +0800 Subject: [PATCH 2/2] test: add Idea reuse scenarios for submitProposal and approveProposal - Test submitProposal with Idea in proposal_created status (updateMany matches 0, no error) - Test approveProposal with Idea in completed status (updateMany matches 0, no error) - All 86 tests pass --- .../__tests__/proposal.service.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/services/__tests__/proposal.service.test.ts b/src/services/__tests__/proposal.service.test.ts index 5fe6318..d924b7e 100644 --- a/src/services/__tests__/proposal.service.test.ts +++ b/src/services/__tests__/proposal.service.test.ts @@ -1711,3 +1711,108 @@ describe("approveProposal - edge cases", () => { expect(mockCreateTasks).toHaveBeenCalled(); }); }); + +// ===== Idea Reuse across Proposals ===== +describe("Idea reuse - submitProposal with proposal_created Idea", () => { + it("should not error when Idea is already in proposal_created status", async () => { + const { submitProposal } = await import("@/services/proposal.service"); + + const now = new Date(); + const proposal = { + uuid: "proposal-reuse", + companyUuid: COMPANY_UUID, + projectUuid: PROJECT_UUID, + status: "draft", + inputType: "idea", + inputUuids: ["idea-already-used"], + documentDrafts: [ + { uuid: "doc-1", type: "prd", title: "PRD", content: "This is a comprehensive PRD document that describes the feature requirements in detail for the Idea reuse feature across multiple proposals." }, + { uuid: "doc-2", type: "tech_design", title: "Tech Design", content: "This is a comprehensive tech design document that describes the implementation approach for the Idea reuse feature across multiple proposals." }, + ], + taskDrafts: [{ + uuid: "task-1", title: "Task", description: "desc", storyPoints: 1, priority: "medium", + acceptanceCriteria: null, acceptanceCriteriaItems: [{ description: "AC1" }], dependsOnDraftUuids: [], + }], + project: { uuid: PROJECT_UUID, name: "Test" }, + description: "Test proposal for Idea reuse scenario", + createdByUuid: ACTOR_UUID, + createdByType: "agent", + reviewedByUuid: null, + reviewNote: null, + reviewedAt: null, + createdAt: now, + updatedAt: now, + }; + + // E5 check: ideas must have resolved elaboration + mockPrisma.idea.findMany.mockResolvedValue([ + { uuid: "idea-already-used", title: "Test Idea", elaborationStatus: "resolved" }, + ]); + + mockPrisma.proposal.findFirst.mockResolvedValue(proposal); + mockPrisma.proposal.update.mockResolvedValue({ ...proposal, status: "pending" }); + // Idea is already proposal_created - updateMany should match 0 rows (no error) + mockPrisma.idea.updateMany.mockResolvedValue({ count: 0 }); + + const result = await submitProposal("proposal-reuse", COMPANY_UUID); + + expect(result.status).toBe("pending"); + // updateMany was called with status: "elaborating" filter, which won't match proposal_created + expect(mockPrisma.idea.updateMany).toHaveBeenCalledWith({ + where: { uuid: { in: ["idea-already-used"] }, companyUuid: COMPANY_UUID, status: "elaborating" }, + data: { status: "proposal_created" }, + }); + }); +}); + +describe("Idea reuse - approveProposal with completed Idea", () => { + it("should not error when Idea is already in completed status", async () => { + const { approveProposal } = await import("@/services/proposal.service"); + + const now = new Date(); + const proposal = { + uuid: "proposal-reuse-2", + companyUuid: COMPANY_UUID, + projectUuid: PROJECT_UUID, + status: "pending", + inputType: "idea", + inputUuids: ["idea-completed"], + documentDrafts: [{ uuid: "doc-1", type: "prd", title: "PRD", content: "content" }], + taskDrafts: [], + project: { uuid: PROJECT_UUID, name: "Test" }, + createdByUuid: ACTOR_UUID, + createdByType: "agent", + reviewedByUuid: null, + reviewNote: null, + reviewedAt: null, + createdAt: now, + updatedAt: now, + }; + + mockPrisma.proposal.findFirst.mockResolvedValue(proposal); + + mockPrisma.$transaction.mockImplementation(async (callback) => { + const txMock = { + proposal: { update: vi.fn().mockResolvedValue({ + ...proposal, status: "approved", + reviewedByUuid: "reviewer-uuid", reviewNote: "Approved", reviewedAt: now, + project: { uuid: PROJECT_UUID, name: "Test" }, + }) }, + taskDependency: { create: vi.fn() }, + acceptanceCriterion: { createMany: vi.fn() }, + }; + return callback(txMock); + }); + mockCreateTasks.mockResolvedValue({ draftToTaskUuidMap: new Map() }); + // Idea is already completed - updateMany should match 0 rows (no error) + mockPrisma.idea.updateMany.mockResolvedValue({ count: 0 }); + + await approveProposal("proposal-reuse-2", COMPANY_UUID, "reviewer-uuid", "Approved"); + + // updateMany was called with status: "proposal_created" filter, which won't match completed + expect(mockPrisma.idea.updateMany).toHaveBeenCalledWith({ + where: { uuid: { in: ["idea-completed"] }, companyUuid: COMPANY_UUID, status: "proposal_created" }, + data: { status: "completed" }, + }); + }); +});