From 88c8988040759fb4c40f7506e41b738f491a209a Mon Sep 17 00:00:00 2001 From: DealPatrol Date: Tue, 2 Jun 2026 03:15:14 -0500 Subject: [PATCH] feat: add Build This App generation workflow Add end-to-end build plan generation for discovered blueprints with a new API route, structured AI output storage, and a card-based UI that supports copy, export, open-in-Cursor, and regenerate actions. Co-authored-by: Cursor --- app/api/blueprints/[id]/build-plan/route.ts | 93 ++++ app/api/setup/init-db/route.ts | 13 + components/analysis-detail.tsx | 24 + components/build-this-app.tsx | 512 ++++++++++++++++++++ lib/build-plan-generation.ts | 321 ++++++++++++ lib/build-plan.ts | 312 ++++++++++++ lib/queries.ts | 57 +++ migrations/007_build_plans.sql | 12 + 8 files changed, 1344 insertions(+) create mode 100644 app/api/blueprints/[id]/build-plan/route.ts create mode 100644 components/build-this-app.tsx create mode 100644 lib/build-plan-generation.ts create mode 100644 lib/build-plan.ts create mode 100644 migrations/007_build_plans.sql diff --git a/app/api/blueprints/[id]/build-plan/route.ts b/app/api/blueprints/[id]/build-plan/route.ts new file mode 100644 index 0000000..7145a3d --- /dev/null +++ b/app/api/blueprints/[id]/build-plan/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCurrentUser } from '@/lib/auth' +import { generateBuildPlanContent } from '@/lib/build-plan-generation' +import { + getBlueprintById, + getBuildPlanByBlueprintId, + getRepositoriesForAnalysis, + upsertBuildPlan, +} from '@/lib/queries' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Sign in with GitHub to view build plans.' }, { status: 401 }) + } + + const { id: blueprintId } = await params + const blueprint = await getBlueprintById(blueprintId) + if (!blueprint) { + return NextResponse.json({ error: 'Blueprint not found' }, { status: 404 }) + } + + const buildPlan = await getBuildPlanByBlueprintId(blueprintId) + if (!buildPlan) { + return NextResponse.json({ buildPlan: null }) + } + + return NextResponse.json({ + buildPlan, + blueprint: { id: blueprint.id, name: blueprint.name }, + }) + } catch (error) { + console.error('[build-plan] GET error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch build plan' }, + { status: 500 }, + ) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Sign in with GitHub to generate build plans.' }, { status: 401 }) + } + + const { id: blueprintId } = await params + const body = await request.json().catch(() => ({})) + const regenerate = Boolean((body as { regenerate?: boolean }).regenerate) + + const blueprint = await getBlueprintById(blueprintId) + if (!blueprint) { + return NextResponse.json({ error: 'Blueprint not found' }, { status: 404 }) + } + + const existing = await getBuildPlanByBlueprintId(blueprintId) + if (existing && !regenerate) { + return NextResponse.json({ + buildPlan: existing, + blueprint: { id: blueprint.id, name: blueprint.name }, + cached: true, + }) + } + + const repositories = await getRepositoriesForAnalysis(blueprint.analysis_id) + const content = await generateBuildPlanContent(blueprint, repositories) + const buildPlan = await upsertBuildPlan({ + blueprint_id: blueprintId, + content, + version: existing ? existing.version + 1 : 1, + }) + + return NextResponse.json({ + buildPlan, + blueprint: { id: blueprint.id, name: blueprint.name }, + cached: false, + }) + } catch (error) { + console.error('[build-plan] POST error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to generate build plan' }, + { status: 500 }, + ) + } +} diff --git a/app/api/setup/init-db/route.ts b/app/api/setup/init-db/route.ts index 76b4f3b..6e72a1e 100644 --- a/app/api/setup/init-db/route.ts +++ b/app/api/setup/init-db/route.ts @@ -133,6 +133,19 @@ async function run() { await sql`CREATE INDEX IF NOT EXISTS idx_analyses_status ON analyses(status)` await sql`CREATE INDEX IF NOT EXISTS idx_app_blueprints_analysis_id ON app_blueprints(analysis_id)` + await sql` + CREATE TABLE IF NOT EXISTS build_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + content JSONB NOT NULL DEFAULT '{}'::jsonb, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(blueprint_id) + ) + ` + await sql`CREATE INDEX IF NOT EXISTS idx_build_plans_blueprint_id ON build_plans(blueprint_id)` + return NextResponse.json({ success: true, message: 'Database schema initialized successfully.' }) } catch (err) { console.error('[setup] DB init failed:', err) diff --git a/components/analysis-detail.tsx b/components/analysis-detail.tsx index 22a711c..d72cde7 100644 --- a/components/analysis-detail.tsx +++ b/components/analysis-detail.tsx @@ -21,8 +21,10 @@ import { Download, Lock, Crown, + Hammer, } from 'lucide-react' import type { Analysis, Repository, AppBlueprint } from '@/lib/queries' +import { BuildThisApp } from '@/components/build-this-app' import { getBlueprintTier, tierCopy, @@ -78,6 +80,7 @@ export function AnalysisDetail({ const isFreePlan = userPlan === 'free' && !isTrialing const viewLimit = blueprintLimit > 0 ? blueprintLimit : Infinity const [scaffoldLoadingId, setScaffoldLoadingId] = useState(null) + const [buildAppBlueprintId, setBuildAppBlueprintId] = useState(null) const [isRunning, setIsRunning] = useState(false) const [status, setStatus] = useState(analysis.status) const [progress, setProgress] = useState( @@ -606,6 +609,13 @@ export function AnalysisDetail({ )} ) : null} + + + + + {buildPlan.version > 1 && ( + + v{buildPlan.version} + + )} + + )} + + + +
+ {loading && ( + + +

Generating your build plan…

+

+ Creating PRD, tech stack, schema, routes, and launch timeline +

+
+ )} + + {error && ( + + {error} + + + )} + + {buildPlan && !loading && ( +
+ {BUILD_PLAN_SECTIONS.map(({ key, title }) => { + const sectionMarkdown = buildPlanSectionToMarkdown(key, buildPlan.content) + return ( + +
+

{title}

+ +
+ +
+ ) + })} +
+ )} +
+
+ + + ) +} diff --git a/lib/build-plan-generation.ts b/lib/build-plan-generation.ts new file mode 100644 index 0000000..c893155 --- /dev/null +++ b/lib/build-plan-generation.ts @@ -0,0 +1,321 @@ +import Anthropic from '@anthropic-ai/sdk' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { + BuildPlanContentSchema, + type BuildPlanContent, +} from '@/lib/build-plan' +import type { AppBlueprint, Repository } from '@/lib/queries' + +const BUILD_PLAN_TOOL_SCHEMA = { + type: 'object' as const, + properties: { + product_requirements: { + type: 'object', + properties: { + overview: { type: 'string' }, + target_users: { type: 'array', items: { type: 'string' } }, + goals: { type: 'array', items: { type: 'string' } }, + features: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + priority: { type: 'string', enum: ['must', 'should', 'could'] }, + }, + required: ['name', 'description', 'priority'], + }, + }, + non_functional: { type: 'array', items: { type: 'string' } }, + }, + required: ['overview', 'target_users', 'goals', 'features', 'non_functional'], + }, + tech_stack: { + type: 'object', + properties: { + frontend: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' }, reason: { type: 'string' } }, + required: ['name', 'reason'], + }, + }, + backend: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' }, reason: { type: 'string' } }, + required: ['name', 'reason'], + }, + }, + database: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' }, reason: { type: 'string' } }, + required: ['name', 'reason'], + }, + }, + infrastructure: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' }, reason: { type: 'string' } }, + required: ['name', 'reason'], + }, + }, + existing_reuse: { type: 'array', items: { type: 'string' } }, + }, + required: ['frontend', 'backend', 'database', 'infrastructure', 'existing_reuse'], + }, + database_schema: { + type: 'object', + properties: { + tables: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + columns: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + type: { type: 'string' }, + constraints: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['name', 'type'], + }, + }, + relationships: { type: 'array', items: { type: 'string' } }, + }, + required: ['name', 'description', 'columns'], + }, + }, + }, + required: ['tables'], + }, + api_routes: { + type: 'array', + items: { + type: 'object', + properties: { + method: { type: 'string' }, + path: { type: 'string' }, + description: { type: 'string' }, + auth_required: { type: 'boolean' }, + }, + required: ['method', 'path', 'description', 'auth_required'], + }, + }, + frontend_pages: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + components: { type: 'array', items: { type: 'string' } }, + }, + required: ['path', 'name', 'description', 'components'], + }, + }, + components: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + path: { type: 'string' }, + description: { type: 'string' }, + props: { type: 'array', items: { type: 'string' } }, + }, + required: ['name', 'path', 'description'], + }, + }, + deployment_plan: { + type: 'object', + properties: { + stages: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + tasks: { type: 'array', items: { type: 'string' } }, + }, + required: ['name', 'description', 'tasks'], + }, + }, + hosting_recommendation: { type: 'string' }, + ci_cd: { type: 'array', items: { type: 'string' } }, + environment_variables: { type: 'array', items: { type: 'string' } }, + }, + required: ['stages', 'hosting_recommendation', 'ci_cd', 'environment_variables'], + }, + monetization: { + type: 'object', + properties: { + model: { type: 'string' }, + suggestions: { + type: 'array', + items: { + type: 'object', + properties: { + strategy: { type: 'string' }, + description: { type: 'string' }, + pricing_hint: { type: 'string' }, + }, + required: ['strategy', 'description'], + }, + }, + }, + required: ['model', 'suggestions'], + }, + mvp_timeline: { + type: 'object', + properties: { + total_weeks: { type: 'number' }, + phases: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + duration: { type: 'string' }, + deliverables: { type: 'array', items: { type: 'string' } }, + dependencies: { type: 'array', items: { type: 'string' } }, + }, + required: ['name', 'duration', 'deliverables'], + }, + }, + }, + required: ['total_weeks', 'phases'], + }, + }, + required: [ + 'product_requirements', + 'tech_stack', + 'database_schema', + 'api_routes', + 'frontend_pages', + 'components', + 'deployment_plan', + 'monetization', + 'mvp_timeline', + ], +} + +function formatBlueprintContext( + blueprint: AppBlueprint, + repositories: Repository[], +): string { + const repoLines = repositories + .map( + (r) => + `- ${r.full_name} (${r.language ?? 'unknown language'}): ${r.description ?? 'No description'}`, + ) + .join('\n') + + const existingFiles = blueprint.existing_files + .map((f) => `- ${f.path}: ${f.purpose}`) + .join('\n') + + const missingFiles = blueprint.missing_files + .map((f) => `- ${f.name}: ${f.purpose}`) + .join('\n') + + return [ + `App name: ${blueprint.name}`, + `Description: ${blueprint.description ?? 'N/A'}`, + `App type: ${blueprint.app_type ?? 'web app'}`, + `Complexity: ${blueprint.complexity}`, + `Reuse percentage: ${blueprint.reuse_percentage}%`, + `Estimated effort: ${blueprint.estimated_effort ?? 'TBD'}`, + `Technologies detected: ${blueprint.technologies.join(', ') || 'none listed'}`, + '', + 'AI explanation:', + blueprint.ai_explanation ?? 'N/A', + '', + 'Source repositories:', + repoLines || '- No repositories linked', + '', + 'Existing reusable files:', + existingFiles || '- None identified', + '', + 'Missing files to build:', + missingFiles || '- None identified', + ].join('\n') +} + +export async function generateBuildPlanContent( + blueprint: AppBlueprint, + repositories: Repository[], +): Promise { + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error('Build plan generation is not configured. Missing ANTHROPIC_API_KEY.') + } + + const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) + const context = formatBlueprintContext(blueprint, repositories) + + const userPrompt = `You are a senior product architect and full-stack engineer. A user discovered this app opportunity by analyzing their GitHub repositories with RepoFuse. + +Generate a complete, actionable build plan for shipping this app as an MVP. Ground every recommendation in the repository analysis context below — reuse existing files where possible, and align the tech stack with detected technologies. + +REPOSITORY ANALYSIS CONTEXT: +${context} + +Requirements: +- Product requirements should be MVP-focused with clear must/should/could priorities +- Tech stack should prefer technologies already present in the user's repos +- Database schema should be practical for the MVP (3-8 tables max) +- API routes should cover core CRUD and auth flows +- Frontend pages and components should map to the missing files where relevant +- Deployment plan should be realistic for a solo developer or small team +- Monetization should include 2-4 concrete strategies +- MVP timeline should be 2-8 weeks total with phased deliverables + +Use the report_build_plan tool to return structured JSON only.` + + const response = await client.messages.create({ + model: getAnthropicModel(), + max_tokens: 16384, + system: [ + { + type: 'text', + text: 'You are an expert product architect. Generate detailed, implementation-ready build plans grounded in actual repository analysis. Be specific with file paths, API routes, and schema columns.', + cache_control: { type: 'ephemeral' }, + }, + ], + tools: [ + { + name: 'report_build_plan', + description: 'Report the complete structured build plan for the app opportunity', + input_schema: BUILD_PLAN_TOOL_SCHEMA, + }, + ], + tool_choice: { type: 'tool', name: 'report_build_plan' }, + messages: [{ role: 'user', content: userPrompt }], + }) + + const toolUse = response.content.find((block) => block.type === 'tool_use') + if (!toolUse || toolUse.type !== 'tool_use') { + throw new Error('AI did not return a structured build plan') + } + + const parsed = BuildPlanContentSchema.safeParse(toolUse.input) + if (!parsed.success) { + throw new Error(`Invalid build plan structure: ${parsed.error.message}`) + } + + return parsed.data +} diff --git a/lib/build-plan.ts b/lib/build-plan.ts new file mode 100644 index 0000000..7921cfb --- /dev/null +++ b/lib/build-plan.ts @@ -0,0 +1,312 @@ +import { z } from 'zod' + +export const BuildPlanContentSchema = z.object({ + product_requirements: z.object({ + overview: z.string(), + target_users: z.array(z.string()), + goals: z.array(z.string()), + features: z.array( + z.object({ + name: z.string(), + description: z.string(), + priority: z.enum(['must', 'should', 'could']), + }), + ), + non_functional: z.array(z.string()), + }), + tech_stack: z.object({ + frontend: z.array(z.object({ name: z.string(), reason: z.string() })), + backend: z.array(z.object({ name: z.string(), reason: z.string() })), + database: z.array(z.object({ name: z.string(), reason: z.string() })), + infrastructure: z.array(z.object({ name: z.string(), reason: z.string() })), + existing_reuse: z.array(z.string()), + }), + database_schema: z.object({ + tables: z.array( + z.object({ + name: z.string(), + description: z.string(), + columns: z.array( + z.object({ + name: z.string(), + type: z.string(), + constraints: z.string().optional(), + description: z.string().optional(), + }), + ), + relationships: z.array(z.string()).optional(), + }), + ), + }), + api_routes: z.array( + z.object({ + method: z.string(), + path: z.string(), + description: z.string(), + auth_required: z.boolean(), + }), + ), + frontend_pages: z.array( + z.object({ + path: z.string(), + name: z.string(), + description: z.string(), + components: z.array(z.string()), + }), + ), + components: z.array( + z.object({ + name: z.string(), + path: z.string(), + description: z.string(), + props: z.array(z.string()).optional(), + }), + ), + deployment_plan: z.object({ + stages: z.array( + z.object({ + name: z.string(), + description: z.string(), + tasks: z.array(z.string()), + }), + ), + hosting_recommendation: z.string(), + ci_cd: z.array(z.string()), + environment_variables: z.array(z.string()), + }), + monetization: z.object({ + model: z.string(), + suggestions: z.array( + z.object({ + strategy: z.string(), + description: z.string(), + pricing_hint: z.string().optional(), + }), + ), + }), + mvp_timeline: z.object({ + total_weeks: z.number(), + phases: z.array( + z.object({ + name: z.string(), + duration: z.string(), + deliverables: z.array(z.string()), + dependencies: z.array(z.string()).optional(), + }), + ), + }), +}) + +export type BuildPlanContent = z.infer + +export interface BuildPlan { + id: string + blueprint_id: string + content: BuildPlanContent + version: number + created_at: string + updated_at: string +} + +export const BUILD_PLAN_SECTIONS = [ + { key: 'product_requirements', title: 'Product Requirements', shortTitle: 'PRD' }, + { key: 'tech_stack', title: 'Recommended Tech Stack', shortTitle: 'Stack' }, + { key: 'database_schema', title: 'Database Schema', shortTitle: 'Schema' }, + { key: 'api_routes', title: 'API Routes', shortTitle: 'API' }, + { key: 'frontend_pages', title: 'Frontend Pages', shortTitle: 'Pages' }, + { key: 'components', title: 'Component List', shortTitle: 'Components' }, + { key: 'deployment_plan', title: 'Deployment Plan', shortTitle: 'Deploy' }, + { key: 'monetization', title: 'Monetization', shortTitle: 'Revenue' }, + { key: 'mvp_timeline', title: 'MVP Timeline', shortTitle: 'Timeline' }, +] as const + +export type BuildPlanSectionKey = (typeof BUILD_PLAN_SECTIONS)[number]['key'] + +export function buildPlanSectionToMarkdown( + key: BuildPlanSectionKey, + content: BuildPlanContent, +): string { + switch (key) { + case 'product_requirements': { + const prd = content.product_requirements + return [ + '# Product Requirements', + '', + prd.overview, + '', + '## Target Users', + ...prd.target_users.map((u) => `- ${u}`), + '', + '## Goals', + ...prd.goals.map((g) => `- ${g}`), + '', + '## Features', + ...prd.features.map( + (f) => `- **[${f.priority.toUpperCase()}]** ${f.name}: ${f.description}`, + ), + '', + '## Non-Functional Requirements', + ...prd.non_functional.map((n) => `- ${n}`), + ].join('\n') + } + case 'tech_stack': { + const stack = content.tech_stack + const formatItems = (label: string, items: { name: string; reason: string }[]) => + items.length + ? [`## ${label}`, ...items.map((i) => `- **${i.name}** — ${i.reason}`), ''] + : [] + return [ + '# Recommended Tech Stack', + '', + ...formatItems('Frontend', stack.frontend), + ...formatItems('Backend', stack.backend), + ...formatItems('Database', stack.database), + ...formatItems('Infrastructure', stack.infrastructure), + '## Existing Code to Reuse', + ...stack.existing_reuse.map((r) => `- ${r}`), + ].join('\n') + } + case 'database_schema': { + const schema = content.database_schema + const tables = schema.tables.map((table) => { + const cols = table.columns + .map( + (c) => + `| ${c.name} | ${c.type} | ${c.constraints ?? ''} | ${c.description ?? ''} |`, + ) + .join('\n') + const rels = table.relationships?.length + ? ['', 'Relationships:', ...table.relationships.map((r) => `- ${r}`)] + : [] + return [ + `## ${table.name}`, + table.description, + '', + '| Column | Type | Constraints | Description |', + '| --- | --- | --- | --- |', + cols, + ...rels, + '', + ].join('\n') + }) + return ['# Database Schema', '', ...tables].join('\n') + } + case 'api_routes': { + return [ + '# API Routes', + '', + '| Method | Path | Auth | Description |', + '| --- | --- | --- | --- |', + ...content.api_routes.map( + (r) => + `| ${r.method} | ${r.path} | ${r.auth_required ? 'Yes' : 'No'} | ${r.description} |`, + ), + ].join('\n') + } + case 'frontend_pages': { + return [ + '# Frontend Pages', + '', + ...content.frontend_pages.flatMap((page) => [ + `## ${page.name} (\`${page.path}\`)`, + page.description, + '', + 'Components:', + ...page.components.map((c) => `- ${c}`), + '', + ]), + ].join('\n') + } + case 'components': { + return [ + '# Component List', + '', + ...content.components.map((c) => { + const props = c.props?.length + ? [`Props: ${c.props.join(', ')}`] + : [] + return [`## ${c.name}`, `\`${c.path}\``, c.description, ...props, ''].join('\n') + }), + ].join('\n') + } + case 'deployment_plan': { + const plan = content.deployment_plan + return [ + '# Deployment Plan', + '', + `**Hosting:** ${plan.hosting_recommendation}`, + '', + '## CI/CD', + ...plan.ci_cd.map((c) => `- ${c}`), + '', + '## Environment Variables', + ...plan.environment_variables.map((v) => `- \`${v}\``), + '', + ...plan.stages.flatMap((stage) => [ + `## ${stage.name}`, + stage.description, + '', + ...stage.tasks.map((t) => `- [ ] ${t}`), + '', + ]), + ].join('\n') + } + case 'monetization': { + const m = content.monetization + return [ + '# Monetization', + '', + `**Primary model:** ${m.model}`, + '', + ...m.suggestions.map((s) => { + const price = s.pricing_hint ? ` (${s.pricing_hint})` : '' + return `- **${s.strategy}**${price}: ${s.description}` + }), + ].join('\n') + } + case 'mvp_timeline': { + const timeline = content.mvp_timeline + return [ + '# MVP Timeline', + '', + `**Total duration:** ${timeline.total_weeks} weeks`, + '', + ...timeline.phases.flatMap((phase) => [ + `## ${phase.name} (${phase.duration})`, + ...phase.deliverables.map((d) => `- ${d}`), + ...(phase.dependencies?.length + ? ['', 'Dependencies:', ...phase.dependencies.map((d) => `- ${d}`)] + : []), + '', + ]), + ].join('\n') + } + default: + return '' + } +} + +export function buildPlanToMarkdown(appName: string, content: BuildPlanContent): string { + const sections = BUILD_PLAN_SECTIONS.map(({ key }) => + buildPlanSectionToMarkdown(key, content), + ) + return [`# Build Plan: ${appName}`, '', ...sections].join('\n\n') +} + +const CURSOR_PROMPT_CHAR_LIMIT = 8000 + +export function buildCursorPrompt(appName: string, content: BuildPlanContent): string { + const markdown = buildPlanToMarkdown(appName, content) + const instruction = `Build the "${appName}" application according to this complete build plan. Follow the tech stack, schema, API routes, pages, and components specified below. Start with the MVP timeline phase 1 deliverables.\n\n` + const full = instruction + markdown + if (full.length <= CURSOR_PROMPT_CHAR_LIMIT) return full + return full.slice(0, CURSOR_PROMPT_CHAR_LIMIT - 20) + '\n\n[Truncated]' +} + +export function buildCursorDeeplinks(prompt: string): { web: string; app: string } { + const encoded = encodeURIComponent(prompt) + return { + web: `https://cursor.com/link/prompt?text=${encoded}`, + app: `cursor://anysphere.cursor-deeplink/prompt?text=${encoded}`, + } +} diff --git a/lib/queries.ts b/lib/queries.ts index 1003c55..546eae2 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -1,4 +1,5 @@ import { getDb } from './db' +import type { BuildPlanContent } from './build-plan' // Types export interface Repository { @@ -319,6 +320,12 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + const sql = getDb() + const rows = await sql`SELECT * FROM app_blueprints WHERE id = ${blueprintId} LIMIT 1` + return (rows[0] as AppBlueprint) || null +} + export async function createBlueprint(data: { analysis_id: string name: string @@ -348,6 +355,56 @@ export async function createBlueprint(data: { return result[0] as AppBlueprint } +// Build plan types & queries + +export interface BuildPlanRecord { + id: string + blueprint_id: string + content: BuildPlanContent + version: number + created_at: string + updated_at: string +} + +export async function getBuildPlanByBlueprintId( + blueprintId: string, +): Promise { + const sql = getDb() + try { + const rows = await sql` + SELECT * FROM build_plans WHERE blueprint_id = ${blueprintId} LIMIT 1 + ` + return (rows[0] as BuildPlanRecord) || null + } catch { + return null + } +} + +export async function upsertBuildPlan(data: { + blueprint_id: string + content: BuildPlanContent + version?: number +}): Promise { + const sql = getDb() + const existing = await getBuildPlanByBlueprintId(data.blueprint_id) + const version = data.version ?? (existing ? existing.version + 1 : 1) + + const result = await sql` + INSERT INTO build_plans (blueprint_id, content, version) + VALUES ( + ${data.blueprint_id}, + ${JSON.stringify(data.content)}::jsonb, + ${version} + ) + ON CONFLICT (blueprint_id) DO UPDATE SET + content = EXCLUDED.content, + version = EXCLUDED.version, + updated_at = CURRENT_TIMESTAMP + RETURNING * + ` + return result[0] as BuildPlanRecord +} + // Gap & Template types export interface MissingFileGap { id: string diff --git a/migrations/007_build_plans.sql b/migrations/007_build_plans.sql new file mode 100644 index 0000000..de04c8a --- /dev/null +++ b/migrations/007_build_plans.sql @@ -0,0 +1,12 @@ +-- Build plans generated from app blueprint opportunities +CREATE TABLE IF NOT EXISTS build_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + content JSONB NOT NULL DEFAULT '{}'::jsonb, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(blueprint_id) +); + +CREATE INDEX IF NOT EXISTS idx_build_plans_blueprint_id ON build_plans(blueprint_id);