Files
vibn-agent-runner/vibn-frontend/app/api/projects/delete/route.ts

280 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 12 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<string, unknown>): {
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<string, unknown>);
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 },
);
}
}