import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; import { pushTurborepoScaffold } from '@/lib/scaffold'; import { createProject as createCoolifyProject, createMonorepoAppService } from '@/lib/coolify'; import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; 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 getServerSession(authOptions); 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; const body = await request.json(); const { projectName, projectType, slug, vision, product, workspacePath, chatgptUrl, githubRepo, githubRepoId, githubRepoUrl, githubDefaultBranch, } = body; // 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 }); } 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, }); console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${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 ${GITEA_ADMIN_USER}/${repoName}`); repo = await getRepo(GITEA_ADMIN_USER, 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 push the default Turborepo scaffold. if (githubRepoUrl) { 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: `${GITEA_ADMIN_USER}/${repoName}`, project_name: projectName, }), }); if (!mirrorRes.ok) { const detail = await mirrorRes.text(); throw new Error(`GitHub mirror failed: ${detail}`); } console.log(`[API] GitHub repo mirrored to ${giteaRepo}`); } else { await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName); console.log(`[API] Turborepo scaffold pushed 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(GITEA_ADMIN_USER, repoName).catch(() => []); const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId)); if (!alreadyHooked) { const hook = await createWebhook(GITEA_ADMIN_USER, 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 Coolify project + per-app services // ────────────────────────────────────────────── const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com'; const appNames = ['product', 'website', 'admin', 'storybook'] as const; const provisionedApps: Array<{ name: string; path: string; coolifyServiceUuid: string | null; domain: string | null; }> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null })); let coolifyProjectUuid: string | null = null; if (giteaCloneUrl) { try { const coolifyProject = await createCoolifyProject( projectName, `Vibn project: ${projectName}` ); coolifyProjectUuid = coolifyProject.uuid; for (const app of provisionedApps) { try { const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`; const service = await createMonorepoAppService({ projectUuid: coolifyProject.uuid, appName: app.name, gitRepo: giteaCloneUrl, domain, }); app.coolifyServiceUuid = service.uuid; app.domain = domain; console.log(`[API] Coolify service created: ${app.name} → ${domain}`); } catch (appErr) { console.error(`[API] Coolify service failed for ${app.name}:`, appErr); } } } catch (coolifyErr) { console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr); } } // ────────────────────────────────────────────── // 3. Provision dedicated Theia workspace // ────────────────────────────────────────────── let theiaWorkspaceUrl: string | null = null; let theiaAppUuid: string | null = null; let theiaError: string | null = null; try { const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo); theiaWorkspaceUrl = workspace.serviceUrl; theiaAppUuid = workspace.serviceName; console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`); } catch (err) { theiaError = err instanceof Error ? err.message : String(err); console.error('[API] Theia workspace provisioning failed (non-fatal):', theiaError); } // ────────────────────────────────────────────── // 4. Save project record // ────────────────────────────────────────────── const projectData = { id: projectId, name: projectName, slug, userId: firebaseUserId, workspace, projectType, productName: product?.name || projectName, productVision: vision || '', isForClient: product?.isForClient || false, 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, // Theia workspace theiaWorkspaceUrl, theiaAppUuid, theiaError, // Context snapshot (kept fresh by webhooks) contextSnapshot: null, // Coolify project — one per VIBN project, scopes all app services + DBs coolifyProjectUuid, // Turborepo monorepo apps — each gets its own Coolify service turboVersion: '2.3.3', apps: provisionedApps, // Import metadata isImport: !!githubRepoUrl, importAnalysisStatus: githubRepoUrl ? 'pending' : null, importAnalysisJobId: null as string | null, createdAt: now, updatedAt: now, }; await query(` INSERT INTO fs_projects (id, data, user_id, workspace, slug) VALUES ($1, $2::jsonb, $3, $4, $5) `, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]); // 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, theiaWorkspaceUrl, theiaError: theiaError ?? 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 } ); } }