Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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") &&
Expand Down
14 changes: 1 addition & 13 deletions src/app/(dashboard)/projects/[uuid]/proposals/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { getServerAuthContext } from "@/lib/auth-server";
import {
createProposal,
checkIdeasAvailability,
checkIdeasAssignee,
type DocumentDraftInput,
type TaskDraftInput,
Expand Down Expand Up @@ -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({
Expand Down
11 changes: 2 additions & 9 deletions src/app/(dashboard)/projects/[uuid]/proposals/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<div className="p-8">
Expand Down
16 changes: 7 additions & 9 deletions src/mcp/tools/pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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 }],
};
}
);
Expand Down
105 changes: 105 additions & 0 deletions src/services/__tests__/proposal.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
});
});
Loading