From 4dd8974b43f612a694d594d5732a64bfbd19c63a Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 4 May 2026 10:39:13 -0700 Subject: [PATCH] fix(delete-project): cascade dev container + Coolify resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old /api/projects/delete only removed the fs_projects row. Everything else — the dev container service in Coolify, the row in fs_project_dev_containers, linked Coolify apps/services, and the per-project Coolify project shell — kept existing as orphans. The biggest user-visible consequence: ghost containers from deleted projects keep counting against the 3-active-dev-container quota, so users hit the cap with stuff they thought was already gone. Smoke test on 2026-05-04 caught the user with 2/3 quota slots held by ghosts from a previous Manifest project + Twenty CRM. New cascade: 1. Stop+delete dev container Coolify service (deleteVolumes, dockerCleanup, deleteConnectedNetworks all true). 2. Delete every fs_project_resources-linked Coolify resource (apps + services; databases preserved because they hold user data and we have no signal the user wanted them destroyed). 3. Delete the per-project Coolify project shell IF no other Vibn project still references it (legacy vibn-ws-* shared projects are skipped). 4. Drop fs_project_dev_containers + fs_project_resources rows. 5. Unlink fs_sessions (preserve content for re-association). 6. Delete fs_projects row last. Deliberately NOT deleted: - Gitea repo (user's code is sacred; URL returned in response). - Sentry project (error history is independent evidence). Failures inside the cascade are logged but don't abort. A partial delete leaves orphans for manual cleanup, which beats a rollback to a half-deleted state. Co-authored-by: Cursor --- app/api/projects/delete/route.ts | 214 ++++++++++++++++++++++++++++--- 1 file changed, 193 insertions(+), 21 deletions(-) diff --git a/app/api/projects/delete/route.ts b/app/api/projects/delete/route.ts index 40128705..ddba0775 100644 --- a/app/api/projects/delete/route.ts +++ b/app/api/projects/delete/route.ts @@ -1,7 +1,51 @@ import { NextResponse } from 'next/server'; -import { authSession } from "@/lib/auth/session-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'; +/** + * 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. Drop fs_project_dev_containers + fs_project_resources rows. + * 5. Unlink fs_sessions from the project. + * 6. Delete the fs_projects row. + * + * What we DO NOT delete: + * - The Gitea repo. The user's code is sacred. Even if the project + * is gone, the repo can be re-cloned manually if they change + * their mind. We log the URL in the response so they have a + * pointer to recover it. + * - The Sentry project. Same logic — error history is independent + * evidence, no need to nuke. + * + * 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. + */ export async function POST(request: Request) { try { const session = await authSession(); @@ -14,9 +58,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } - // Verify ownership - const rows = await query<{ id: string; data: any }>(` - SELECT p.id, p.data + // 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 @@ -27,30 +71,158 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Project not found or unauthorized' }, { status: 404 }); } - // Unlink sessions - const sessionResult = await query(` - SELECT COUNT(*)::int AS count FROM fs_sessions WHERE data->>'projectId' = $1 - `, [projectId]); - const sessionCount = sessionResult[0]?.count || 0; + const project = rows[0]; + const data = project.data || {}; + const coolifyProjectUuid: string | null = data.coolifyProjectUuid ?? null; + const giteaRepoUrl: string | null = data.giteaRepoUrl ?? null; - await query(` - UPDATE fs_sessions - SET data = jsonb_set( - jsonb_set(data, '{projectId}', 'null'), - '{needsProjectAssociation}', 'true' - ) - WHERE data->>'projectId' = $1 - `, [projectId]); + const teardown = { + devContainerDeleted: false as boolean | string, + resourcesDeleted: 0, + resourcesFailed: 0, + coolifyProjectDeleted: false as boolean | string, + sessionsUnlinked: 0, + giteaRepoUrl, + }; - // Delete the project + // 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. 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], + ); + + // 5. 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 successfully', sessionsPreserved: sessionCount }); + 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 } + { + error: 'Failed to delete project', + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, ); } }