import { NextResponse } from 'next/server'; import { authSession } from "@/lib/auth/session-server"; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces'; import { ensureProjectCoolifyProject } from '@/lib/projects'; import { ensureSentryProject } from '@/lib/integrations/sentry'; import { assertProjectQuota, QuotaExceededError } from '@/lib/quotas'; import { loadGithubIntegration } from '@/lib/integrations/github'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; import { getStarterKit } from '@/lib/design-kits/registry'; import type { DesignKitPersisted } from '@/lib/design-kits/types'; import { DEFAULT_DESIGN_KIT_ID } from '@/lib/design-kits/types'; import { normalizeSeedDocument } from '@/lib/server/parse-seed-document'; const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT; const APP_URL = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? 'https://vibnai.com'; const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-secret'; export async function POST(request: Request) { try { const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const email = session.user.email; const workspace = email.split('@')[0].toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-account'; // Upsert user into fs_users — guarantees the FK target exists const userData = JSON.stringify({ email, name: session.user.name, image: session.user.image, workspace, }); const existingUser = await query<{ id: string; data: any }>( `SELECT id, data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, [email] ); if (existingUser.length === 0) { await query( `INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb)`, [session.user.id, userData] ); } else { await query( `UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`, [session.user.id, userData, existingUser[0].id] ); } // Fetch the canonical fs_users row (now guaranteed to exist) const users = await query<{ id: string; data: any }>(` SELECT id, data FROM fs_users WHERE data->>'email' = $1 LIMIT 1 `, [email]); const firebaseUserId = users[0]!.id; // Resolve (and lazily provision) the user's workspace. Provides: // - vibnWorkspace.coolify_project_uuid → namespace for Coolify apps/DBs // - vibnWorkspace.gitea_org → owner for Gitea repos // If provisioning failed for either, we fall back to legacy admin // identifiers so the project create still succeeds (with degraded isolation). let vibnWorkspace = await getOrCreateProvisionedWorkspace({ userId: firebaseUserId, email, displayName: session.user.name ?? null, }); const repoOwner = vibnWorkspace.gitea_org ?? GITEA_ADMIN_USER; const body = await request.json(); const { projectName, projectType, slug, vision, product, workspacePath, chatgptUrl, githubRepo, githubRepoId, githubRepoUrl, githubDefaultBranch, githubToken, creationMode, sourceData, audience, designKitId: rawDesignKitId, seedDocument: seedDocumentRaw, } = body; const requestedKit = typeof rawDesignKitId === 'string' ? rawDesignKitId.trim() : ''; const resolvedDesignKitId = getStarterKit(requestedKit) ? requestedKit : DEFAULT_DESIGN_KIT_ID; const designKit: DesignKitPersisted = { kitId: resolvedDesignKitId, perKit: {}, }; let seedDocumentPersisted: Awaited> = null; try { seedDocumentPersisted = await normalizeSeedDocument(seedDocumentRaw); } catch (seedErr) { const msg = seedErr instanceof Error ? seedErr.message : String(seedErr); return NextResponse.json({ error: msg }, { status: 400 }); } const mergedSource: Record = sourceData != null && typeof sourceData === 'object' && !Array.isArray(sourceData) ? { ...(sourceData as Record) } : {}; delete mergedSource.seedDocument; // Check slug uniqueness const existing = await query(`SELECT id FROM fs_projects WHERE slug = $1 LIMIT 1`, [slug]); if (existing.length > 0) { return NextResponse.json({ error: 'Project slug already exists' }, { status: 400 }); } // Per-workspace project quota — soft cap at VIBN_QUOTA_MAX_PROJECTS_PER_WORKSPACE // (default 3) to bound runaway resource cost from bad-actor signups. try { await assertProjectQuota({ id: vibnWorkspace.id, slug: workspace }); } catch (qe) { if (qe instanceof QuotaExceededError) { return NextResponse.json( { error: qe.message, code: qe.code, current: qe.current, limit: qe.limit }, { status: 402 }, // 402 Payment Required — semantically "you've hit a tier limit". ); } throw qe; } const projectId = randomUUID(); const now = new Date().toISOString(); // ────────────────────────────────────────────── // 1. Provision Gitea repo // ────────────────────────────────────────────── let giteaRepo: string | null = null; let giteaRepoUrl: string | null = null; let giteaCloneUrl: string | null = null; let giteaSshUrl: string | null = null; let giteaWebhookId: number | null = null; let giteaError: string | null = null; try { const repoName = slug; // e.g. "taskmaster" let repo; try { repo = await createRepo(repoName, { description: `${projectName} — managed by Vibn`, private: true, auto_init: false, owner: repoOwner, }); console.log(`[API] Gitea repo created: ${repoOwner}/${repoName}`); } catch (createErr) { const msg = createErr instanceof Error ? createErr.message : String(createErr); // 409 = repo already exists — link to it instead of failing if (msg.includes('409')) { console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`); repo = await getRepo(repoOwner, repoName); if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`); } else { throw createErr; } } giteaRepo = repo.full_name; // e.g. "mark/taskmaster" giteaRepoUrl = repo.html_url; // e.g. "https://git.vibnai.com/mark/taskmaster" giteaCloneUrl = repo.clone_url; giteaSshUrl = repo.ssh_url; // If a GitHub repo was provided, mirror it as-is. // Otherwise leave the repo empty — the user (or AI) decides what to // put in it. The old turborepo auto-scaffold is no longer pushed // because most projects don't need a 4-app monorepo and the AI // can scaffold whatever the user actually wants on demand. if (githubRepoUrl) { // Prefer an explicitly-passed token; otherwise fall back to the // OAuth-linked token on this user's account so private mirrors // work without the user pasting a PAT. let effectiveToken = githubToken as string | undefined; if (!effectiveToken) { const link = await loadGithubIntegration(email); if (link) effectiveToken = link.token; } const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333'; const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ github_url: githubRepoUrl, gitea_repo: `${repoOwner}/${repoName}`, project_name: projectName, github_token: effectiveToken || undefined, }), }); if (!mirrorRes.ok) { const detail = await mirrorRes.text(); throw new Error(`GitHub mirror failed: ${detail}`); } console.log(`[API] GitHub repo mirrored to ${giteaRepo}`); } // Register webhook — skip if one already points to this project const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`; const existingHooks = await listWebhooks(repoOwner, repoName).catch(() => []); const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId)); if (!alreadyHooked) { const hook = await createWebhook(repoOwner, repoName, webhookUrl, GITEA_WEBHOOK_SECRET); giteaWebhookId = hook.id; console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`); } else { giteaWebhookId = existingHooks.find(h => h.config.url.includes(projectId))?.id ?? null; console.log(`[API] Webhook already exists for ${giteaRepo}`); } } catch (err) { // Non-fatal — log and continue. Project is still created. giteaError = err instanceof Error ? err.message : String(err); console.error('[API] Gitea provisioning failed (non-fatal):', giteaError); } // ────────────────────────────────────────────── // 2. Provision a dedicated Coolify project for this Vibn project // ────────────────────────────────────────────── // Each Vibn project gets its OWN Coolify project named // `vibn-{workspace-slug}-{project-slug}`. All apps/databases/services // the user (or AI) deploys for this project will land inside it. // // We don't pre-create any services anymore — the project starts empty // and the user/AI decides what to deploy via apps_create on demand. // // Note: ensureProjectCoolifyProject reads the fs_projects row, so we // insert a minimal row first, then provision Coolify, then update the // row with the full project data further below. await query( `INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id) VALUES ($1, '{}'::jsonb, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING`, [projectId, firebaseUserId, workspace, slug, vibnWorkspace.id], ); const coolifyProjectUuid: string | null = await ensureProjectCoolifyProject( projectId, vibnWorkspace, { projectSlug: slug, projectName }, ); // Sentry-as-product: provision a Sentry project under the // shared `vibnai` org so any Coolify app deployed for this // Vibn project has a DSN waiting in env vars on first build. // Soft-fails — project create still succeeds without Sentry, // and apps.create will lazily retry the provisioning later. try { await ensureSentryProject({ projectId, workspaceSlug: workspace, projectSlug: slug, projectName, }); } catch (sentryErr) { console.warn('[API] Sentry provisioning failed (non-fatal):', sentryErr); } // ────────────────────────────────────────────── // 3. Save project record // ────────────────────────────────────────────── const projectData = { id: projectId, name: projectName, slug, userId: firebaseUserId, workspace, projectType, productName: product?.name || projectName, productVision: vision || '', // "team" = internal users (your team / employees) — SSO-style auth, no // payments by default. "customers" = external public — sign-up // + payments + transactional email by default. Drives which // Infrastructure tiles get pre-staged. audience: audience === "team" ? "team" : "customers", hasLogo: product?.hasLogo || false, hasDomain: product?.hasDomain || false, hasWebsite: product?.hasWebsite || false, hasGithub: !!githubRepo, hasChatGPT: !!chatgptUrl, workspacePath: workspacePath || null, workspaceName: workspacePath ? workspacePath.split('/').pop() : null, githubRepo: githubRepo || null, githubRepoId: githubRepoId || null, githubRepoUrl: githubRepoUrl || null, githubDefaultBranch: githubDefaultBranch || null, chatgptUrl: chatgptUrl || null, extensionLinked: false, status: 'active', currentPhase: 'collector', phaseStatus: 'not_started', phaseData: {} as ProjectPhaseData, phaseScores: {} as ProjectPhaseScores, // Gitea provisioning giteaRepo, giteaRepoUrl, giteaCloneUrl, giteaSshUrl, giteaWebhookId, giteaError, // Context snapshot (kept fresh by webhooks) contextSnapshot: null, // Coolify project — one per VIBN project, scopes all app services + DBs. // Apps are deployed on-demand via apps_create (no auto-scaffold). coolifyProjectUuid, // How this project was created — drives the AI's first-chat seed. // Shape: { mode: "build"|"oss"|"import", sourceData: {...} } where // sourceData is the path-specific payload from the wizard. creationMode: creationMode ?? null, kickoff: creationMode ? { mode: creationMode, sourceData: { ...mergedSource, designKitId: resolvedDesignKitId, ...(seedDocumentPersisted ? { seedDocument: seedDocumentPersisted } : {}), }, vision: vision || null, createdAt: now, } : null, designKit, // Import metadata isImport: !!githubRepoUrl, importAnalysisStatus: githubRepoUrl ? 'pending' : null, importAnalysisJobId: null as string | null, createdAt: now, updatedAt: now, }; // Update the row we pre-inserted above with the full project data. // We merge with existing data so the coolifyProjectUuid set by // ensureProjectCoolifyProject() above is preserved. await query(` UPDATE fs_projects SET data = data || $2::jsonb, user_id = $3, workspace = $4, slug = $5, vibn_workspace_id = $6, updated_at = NOW() WHERE id = $1 `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]); // Associate any unlinked sessions for this workspace path if (workspacePath) { await query(` UPDATE fs_sessions SET data = jsonb_set( jsonb_set(data, '{projectId}', $1::jsonb), '{needsProjectAssociation}', 'false' ) WHERE user_id = $2 AND data->>'workspacePath' = $3 AND (data->>'needsProjectAssociation')::boolean = true `, [JSON.stringify(projectId), firebaseUserId, workspacePath]); } // ────────────────────────────────────────────── // 5. If this is an import, trigger the analysis agent // ────────────────────────────────────────────── let analysisJobId: string | null = null; if (githubRepoUrl && giteaRepo) { try { const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333'; const jobRes = await fetch(`${agentRunnerUrl}/api/agent/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: 'ImportAnalyzer', task: `Analyze this imported codebase (originally from ${githubRepoUrl}) and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`, repo: giteaRepo, }), }); if (jobRes.ok) { const jobData = await jobRes.json() as { jobId?: string }; analysisJobId = jobData.jobId ?? null; // Store the job ID on the project record if (analysisJobId) { await query( `UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`, [JSON.stringify(analysisJobId), projectId] ); } console.log(`[API] Import analysis job started: ${analysisJobId}`); } } catch (analysisErr) { console.error('[API] Failed to start import analysis (non-fatal):', analysisErr); } } console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped', '| import:', !!githubRepoUrl); return NextResponse.json({ success: true, projectId, slug, workspace, gitea: giteaRepo ? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl } : null, giteaError: giteaError ?? undefined, isImport: !!githubRepoUrl, analysisJobId: analysisJobId ?? undefined, }); } catch (error) { console.error('[POST /api/projects/create] Error:', error); return NextResponse.json( { error: 'Failed to create project', details: error instanceof Error ? error.message : String(error) }, { status: 500 } ); } }