fix(delete-project): cascade dev container + Coolify resources

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-04 10:39:13 -07:00
parent 836733536e
commit 4dd8974b43

View File

@@ -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 },
);
}
}