import { NextResponse } from 'next/server'; import { authSession } from '@/lib/auth/session-server'; import { query } from '@/lib/db-postgres'; import { deleteService, deleteApplication, deleteProject as deleteCoolifyProject, } from '@/lib/coolify'; import { ensureProjectResourcesTable, getProjectResourceUuids, } from '@/lib/projects'; import { deleteRepo } from '@/lib/gitea'; /** * POST /api/projects/delete * * Tears down a Vibn project end-to-end. * * Old behavior (broken): only deleted the fs_projects row. The dev * container, Coolify project, and project_resources links all kept * existing — counting against quota and showing up in API listings as * orphans. This caused users to hit the 3-active-dev-container cap * with ghosts from projects they thought they had deleted. * * New behavior (this file): cascade. * * 1. Stop and delete the dev container Coolify service. * 2. Delete every linked Coolify resource (apps + services + DBs the * AI created under this project). * 3. Delete the per-project Coolify project shell (if it ends up * empty after step 2 — best effort, ignore failure). * 4. Delete the Gitea repo backing this project (best effort). * 5. Unlink fs_sessions from the project. * 6. Delete the fs_projects row. * * Note: fs_project_dev_containers and fs_project_resources rows are * cleared during steps 1–2 as part of container/resource teardown. * * What we DO NOT delete: * - The Sentry project — error history is independent evidence. * * Failures inside the cascade are logged but don't abort the whole * delete. Half-deleted projects are already broken; finishing the * delete and leaving an orphan Coolify resource for manual cleanup * is strictly better than rolling back to a half-state. */ /** Resolve `owner/repo` from project JSON (`giteaRepo` or parsed URL). */ function resolveGiteaOwnerRepo(data: Record): { owner: string; repo: string; } | null { const full = typeof data.giteaRepo === 'string' ? data.giteaRepo.trim() : ''; if (full) { const idx = full.indexOf('/'); if (idx > 0 && idx < full.length - 1) { return { owner: full.slice(0, idx), repo: full.slice(idx + 1) }; } } const urlStr = typeof data.giteaRepoUrl === 'string' ? data.giteaRepoUrl.trim() : ''; if (urlStr) { try { const u = new URL(urlStr); const segs = u.pathname .replace(/^\/+|\/+$/g, '') .split('/') .filter(Boolean); if (segs.length >= 2) { const repoName = segs[segs.length - 1]!.replace(/\.git$/i, ''); const ownerName = segs[segs.length - 2]!; if (ownerName && repoName) return { owner: ownerName, repo: repoName }; } } catch { /* ignore invalid URL */ } } return null; } export async function POST(request: Request) { try { const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { projectId } = await request.json(); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // Verify ownership before we destroy anything. const rows = await query<{ id: string; data: any; workspace: string | null }>(` SELECT p.id, p.data, p.workspace 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, session.user.email]); if (rows.length === 0) { return NextResponse.json({ error: 'Project not found or unauthorized' }, { status: 404 }); } const project = rows[0]; const data = project.data || {}; const coolifyProjectUuid: string | null = data.coolifyProjectUuid ?? null; const giteaRepoUrl: string | null = data.giteaRepoUrl ?? null; const teardown = { devContainerDeleted: false as boolean | string, resourcesDeleted: 0, resourcesFailed: 0, coolifyProjectDeleted: false as boolean | string, giteaRepoDeleted: false as boolean | string, sessionsUnlinked: 0, giteaRepoUrl, }; // 1. Dev container — fetch row, then call Coolify delete-service // with deleteVolumes=true and dockerCleanup=true so the // /workspace volume goes away with the container. Otherwise // abandoned volumes pile up on the host. try { const dcRows = await query<{ service_uuid: string }>( `SELECT service_uuid FROM fs_project_dev_containers WHERE project_id = $1`, [projectId], ); if (dcRows[0]?.service_uuid) { try { await deleteService(dcRows[0].service_uuid, { deleteVolumes: true, dockerCleanup: true, deleteConnectedNetworks: true, }); teardown.devContainerDeleted = true; } catch (err) { teardown.devContainerDeleted = 'coolify_delete_failed:' + (err instanceof Error ? err.message.slice(0, 120) : 'unknown'); } await query( `DELETE FROM fs_project_dev_containers WHERE project_id = $1`, [projectId], ); } } catch (err) { console.warn(`[delete-project ${projectId}] dev container teardown failed`, err); } // 2. Linked Coolify resources (apps, services, databases the AI // created via apps_create / services_create / databases_create). try { await ensureProjectResourcesTable(); const linked = await getProjectResourceUuids(projectId); for (const [uuid, type] of linked.entries()) { try { if (type === 'application') { await deleteApplication(uuid, { deleteConfigurations: true, deleteVolumes: true, dockerCleanup: true, deleteConnectedNetworks: true, }); } else if (type === 'service') { await deleteService(uuid, { deleteConfigurations: true, deleteVolumes: true, dockerCleanup: true, deleteConnectedNetworks: true, }); } // database type intentionally not auto-deleted yet — Coolify // databases hold user data and we don't have a reliable signal // that the user intended to throw it away. Surface them in // the response so the user (or AI) can clean up manually. teardown.resourcesDeleted++; } catch (err) { teardown.resourcesFailed++; console.warn( `[delete-project ${projectId}] failed to delete ${type} ${uuid}`, err, ); } } await query( `DELETE FROM fs_project_resources WHERE project_id = $1`, [projectId], ); } catch (err) { console.warn(`[delete-project ${projectId}] resource cleanup failed`, err); } // 3. Per-project Coolify project shell. Only delete it if it's // a per-project shell (one project_uuid per Vibn project, // populated since Path B week 2). Legacy `vibn-ws-*` shared // projects are NOT deleted because other Vibn projects still // live in them. if (coolifyProjectUuid) { try { // Only delete if no other Vibn project still references it. const sharers = await query<{ count: number }>( `SELECT COUNT(*)::int AS count FROM fs_projects WHERE id <> $1 AND data->>'coolifyProjectUuid' = $2`, [projectId, coolifyProjectUuid], ); if ((sharers[0]?.count ?? 0) === 0) { try { await deleteCoolifyProject(coolifyProjectUuid); teardown.coolifyProjectDeleted = true; } catch (err) { teardown.coolifyProjectDeleted = 'coolify_delete_failed:' + (err instanceof Error ? err.message.slice(0, 120) : 'unknown'); } } else { teardown.coolifyProjectDeleted = 'skipped_shared'; } } catch (err) { console.warn(`[delete-project ${projectId}] coolify project teardown failed`, err); } } // 4. Gitea repo — remove canonical repo so slug/repo name can be reused. const giteaTarget = resolveGiteaOwnerRepo(data as Record); if (giteaTarget) { try { await deleteRepo(giteaTarget.owner, giteaTarget.repo); teardown.giteaRepoDeleted = true; } catch (err) { teardown.giteaRepoDeleted = 'gitea_delete_failed:' + (err instanceof Error ? err.message.slice(0, 200) : 'unknown'); console.warn( `[delete-project ${projectId}] Gitea delete failed for ${giteaTarget.owner}/${giteaTarget.repo}`, err, ); } } // 5. Unlink sessions (preserve their content — they may reference // learnings the user wants under a different project later). const sessionResult = await query<{ count: number }>( `SELECT COUNT(*)::int AS count FROM fs_sessions WHERE data->>'projectId' = $1`, [projectId], ); teardown.sessionsUnlinked = sessionResult[0]?.count ?? 0; await query( `UPDATE fs_sessions SET data = jsonb_set( jsonb_set(data, '{projectId}', 'null'), '{needsProjectAssociation}', 'true' ) WHERE data->>'projectId' = $1`, [projectId], ); // 6. Drop the project row last so a partial cascade above // doesn't leave the project record in a misleading state. await query(`DELETE FROM fs_projects WHERE id = $1`, [projectId]); return NextResponse.json({ success: true, message: 'Project deleted.', teardown, }); } catch (error) { console.error('[POST /api/projects/delete] Error:', error); return NextResponse.json( { error: 'Failed to delete project', details: error instanceof Error ? error.message : String(error), }, { status: 500 }, ); } }