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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user