feat(plan): add Plan tab as the first project surface
A new home for everything that happens BEFORE building: - Vision — one-line elevator pitch (mirrors productVision) - Ideas — the "park-it" bin for raw thoughts - Tasks — what needs to happen next (open / done) - Decisions — log of "we chose X over Y because Z" Storage is appended under fs_projects.data.plan so no schema migration is needed. CRUD lives at /api/projects/[projectId]/plan. The bare project URL now redirects to /plan instead of /product, and the AI chat receives decisions + open tasks in its active-project context block — so it stops re-litigating settled questions and knows what's queued up. Made-with: Cursor
This commit is contained in:
@@ -74,6 +74,32 @@ export function buildSystemPrompt(
|
||||
// at the top so the model treats `projectId` as resolved without the
|
||||
// user having to name it. Falls through to the workspace-level mode
|
||||
// (browse all projects) when activeProject is undefined.
|
||||
// Pull plan artifacts (decisions + open tasks) so the AI doesn't ask
|
||||
// the user to re-decide settled questions and knows what's queued up.
|
||||
// Decisions are first-class: they encode the founder's intent and
|
||||
// should be honored unless the user explicitly revisits one.
|
||||
const plan = (activeProject?.plan ?? {}) as {
|
||||
decisions?: { title: string; choice: string; why?: string }[];
|
||||
tasks?: { text: string; status: "open" | "done" }[];
|
||||
ideas?: { text: string }[];
|
||||
};
|
||||
const decisionsBlock = plan.decisions?.length
|
||||
? `\n**Decisions already made for this project (DO NOT re-litigate unless the user asks):**\n${plan.decisions
|
||||
.slice(0, 20)
|
||||
.map((d) => `- ${d.title} → ${d.choice}${d.why ? ` (because: ${d.why})` : ''}`)
|
||||
.join('\n')}\n`
|
||||
: '';
|
||||
const openTasks = (plan.tasks ?? []).filter((t) => t.status === 'open').slice(0, 15);
|
||||
const tasksBlock = openTasks.length
|
||||
? `\n**Open tasks the user has captured:**\n${openTasks.map((t) => `- ${t.text}`).join('\n')}\n`
|
||||
: '';
|
||||
const ideasBlock = plan.ideas?.length
|
||||
? `\n**Ideas parked (not commitments — surface only if relevant):**\n${plan.ideas
|
||||
.slice(0, 10)
|
||||
.map((i) => `- ${i.text}`)
|
||||
.join('\n')}\n`
|
||||
: '';
|
||||
|
||||
const activeBlock = activeProject
|
||||
? `\n## ACTIVE PROJECT — assume this for every tool call unless the user explicitly says otherwise
|
||||
|
||||
@@ -84,7 +110,7 @@ The user is currently looking at:
|
||||
- Audience: ${activeProject.audience ?? 'unspecified'}
|
||||
- Vision: ${activeProject.productVision ? activeProject.productVision.slice(0, 240) : '(not yet captured)'}
|
||||
${activeProject.kickoff ? `- Created via: ${activeProject.kickoff.mode} (${JSON.stringify(activeProject.kickoff.sourceData).slice(0, 200)})` : ''}
|
||||
|
||||
${decisionsBlock}${tasksBlock}${ideasBlock}
|
||||
When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.\n`
|
||||
: '';
|
||||
|
||||
|
||||
220
app/api/projects/[projectId]/plan/route.ts
Normal file
220
app/api/projects/[projectId]/plan/route.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* /api/projects/[projectId]/plan
|
||||
*
|
||||
* Project-level "thinking" surface — the home of the Plan tab.
|
||||
* Stores everything that happens BEFORE building:
|
||||
* - vision — one-line elevator pitch (mirrored to data.productVision)
|
||||
* - ideas — unstructured capture (the "park-it" bin)
|
||||
* - tasks — what needs to happen next (status: open | done)
|
||||
* - decisions — log of "we chose X over Y because Z"
|
||||
*
|
||||
* Storage lives under fs_projects.data.plan.{ideas,tasks,decisions}
|
||||
* so we don't need a schema migration. Items are appended-and-mutated
|
||||
* in place; we never load the full document back to the client without
|
||||
* an ownership check.
|
||||
*
|
||||
* Methods:
|
||||
* GET → full plan snapshot
|
||||
* POST { kind: "vision", text } → set vision (also mirrors to productVision)
|
||||
* POST { kind: "idea", text } → add idea
|
||||
* POST { kind: "task", text } → add task (status="open")
|
||||
* POST { kind: "decision", title, choice, why? } → add decision
|
||||
* PATCH { kind, id, ... } → update one item (toggle task, edit text)
|
||||
* DELETE?kind=…&id=… → remove one item
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
interface Idea { id: string; text: string; createdAt: string }
|
||||
interface Task { id: string; text: string; status: "open" | "done"; createdAt: string; doneAt?: string }
|
||||
interface Decision { id: string; title: string; choice: string; why?: string; createdAt: string }
|
||||
interface PlanShape {
|
||||
vision?: string;
|
||||
ideas: Idea[];
|
||||
tasks: Task[];
|
||||
decisions: Decision[];
|
||||
}
|
||||
|
||||
function emptyPlan(): PlanShape {
|
||||
return { ideas: [], tasks: [], decisions: [] };
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
return Math.random().toString(36).slice(2, 11);
|
||||
}
|
||||
|
||||
async function loadOwnedProject(projectId: string, email: string) {
|
||||
const rows = await query<{ id: string; data: any }>(
|
||||
`SELECT p.id, p.data FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, email],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
function readPlan(data: any): PlanShape {
|
||||
const raw = (data?.plan ?? {}) as Partial<PlanShape>;
|
||||
return {
|
||||
vision: data?.productVision ?? raw.vision,
|
||||
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
|
||||
tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
|
||||
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function writePlan(projectId: string, plan: PlanShape, alsoVision?: string) {
|
||||
// Use a single jsonb_set call so we don't race with other writers.
|
||||
// When the vision changes we also mirror it to productVision since
|
||||
// it's the canonical field elsewhere in the app.
|
||||
if (alsoVision !== undefined) {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(
|
||||
jsonb_set(data, '{plan}', $2::jsonb, true),
|
||||
'{productVision}', to_jsonb($3::text), true
|
||||
)
|
||||
WHERE id = $1`,
|
||||
[projectId, JSON.stringify({ ...plan, vision: undefined }), alsoVision],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{plan}', $2::jsonb, true)
|
||||
WHERE id = $1`,
|
||||
[projectId, JSON.stringify({ ...plan, vision: undefined })],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
ctx: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await ctx.params;
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const project = await loadOwnedProject(projectId, session.user.email);
|
||||
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ plan: readPlan(project.data) });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
ctx: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await ctx.params;
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const project = await loadOwnedProject(projectId, session.user.email);
|
||||
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const kind = String(body.kind ?? "");
|
||||
const plan = readPlan(project.data);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (kind === "vision") {
|
||||
const text = String(body.text ?? "").trim();
|
||||
plan.vision = text;
|
||||
await writePlan(projectId, plan, text);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
if (kind === "idea") {
|
||||
const text = String(body.text ?? "").trim();
|
||||
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
|
||||
plan.ideas.unshift({ id: newId(), text, createdAt: now });
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
if (kind === "task") {
|
||||
const text = String(body.text ?? "").trim();
|
||||
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
|
||||
plan.tasks.unshift({ id: newId(), text, status: "open", createdAt: now });
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
if (kind === "decision") {
|
||||
const title = String(body.title ?? "").trim();
|
||||
const choice = String(body.choice ?? "").trim();
|
||||
const why = body.why ? String(body.why).trim() : undefined;
|
||||
if (!title || !choice) return NextResponse.json({ error: "title and choice required" }, { status: 400 });
|
||||
plan.decisions.unshift({ id: newId(), title, choice, why, createdAt: now });
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
return NextResponse.json({ error: "unknown kind" }, { status: 400 });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
ctx: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await ctx.params;
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const project = await loadOwnedProject(projectId, session.user.email);
|
||||
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { kind, id } = body;
|
||||
const plan = readPlan(project.data);
|
||||
|
||||
if (kind === "task" && id) {
|
||||
const t = plan.tasks.find((x) => x.id === id);
|
||||
if (!t) return NextResponse.json({ error: "task not found" }, { status: 404 });
|
||||
if (typeof body.text === "string") t.text = body.text.trim();
|
||||
if (body.status === "open" || body.status === "done") {
|
||||
t.status = body.status;
|
||||
t.doneAt = body.status === "done" ? new Date().toISOString() : undefined;
|
||||
}
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
if (kind === "idea" && id) {
|
||||
const i = plan.ideas.find((x) => x.id === id);
|
||||
if (!i) return NextResponse.json({ error: "idea not found" }, { status: 404 });
|
||||
if (typeof body.text === "string") i.text = body.text.trim();
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
if (kind === "decision" && id) {
|
||||
const d = plan.decisions.find((x) => x.id === id);
|
||||
if (!d) return NextResponse.json({ error: "decision not found" }, { status: 404 });
|
||||
if (typeof body.title === "string") d.title = body.title.trim();
|
||||
if (typeof body.choice === "string") d.choice = body.choice.trim();
|
||||
if (typeof body.why === "string") d.why = body.why.trim();
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
return NextResponse.json({ error: "unsupported patch" }, { status: 400 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
ctx: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const { projectId } = await ctx.params;
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const project = await loadOwnedProject(projectId, session.user.email);
|
||||
if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const kind = searchParams.get("kind") || "";
|
||||
const id = searchParams.get("id") || "";
|
||||
const plan = readPlan(project.data);
|
||||
|
||||
if (kind === "task") plan.tasks = plan.tasks.filter((x) => x.id !== id);
|
||||
if (kind === "idea") plan.ideas = plan.ideas.filter((x) => x.id !== id);
|
||||
if (kind === "decision") plan.decisions = plan.decisions.filter((x) => x.id !== id);
|
||||
|
||||
await writePlan(projectId, plan);
|
||||
return NextResponse.json({ plan });
|
||||
}
|
||||
Reference in New Issue
Block a user