280 lines
9.8 KiB
TypeScript
280 lines
9.8 KiB
TypeScript
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<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 },
|
||
);
|
||
}
|
||
}
|