/** * Vibn projects — per-project resource isolation. * * Each Vibn project lives inside a Workspace and owns its OWN Coolify Project * (named `vibn-{workspace-slug}-{project-slug}`). All apps, databases, and * services deployed for the project land inside that Coolify project, giving * us: * - clean grouping in the Coolify UI * - cascading delete (drop project → drop all its resources) * - per-project billing/usage attribution * - per-project domain namespace (`*.{project-slug}.{workspace-slug}.vibnai.com`) * * The mapping is stored on fs_projects.data.coolifyProjectUuid. Helpers below * are idempotent — safe to call repeatedly. */ import { query, queryOne } from '@/lib/db-postgres'; import { createProject as createCoolifyProject, listProjects as listCoolifyProjects } from '@/lib/coolify'; import type { VibnWorkspace } from '@/lib/workspaces'; export interface VibnProjectRow { id: string; data: any; workspace: string; slug: string; user_id: string; vibn_workspace_id: string | null; created_at: Date; updated_at: Date; } /** Coolify Project name we use for a Vibn project. */ export function coolifyProjectNameForVibnProject(workspaceSlug: string, projectSlug: string): string { return `vibn-${workspaceSlug}-${projectSlug}`; } /** * Idempotently ensure the given Vibn project has its own Coolify Project. * Returns the Coolify project UUID. Persists it to fs_projects.data.coolifyProjectUuid. * * - If already stored, returns immediately. * - If not stored, looks up by name in Coolify (handles re-runs after a * half-failed previous create) and either reuses or creates fresh. * - Falls back to the workspace's legacy `vibn-ws-{slug}` project on Coolify * failure so deploys aren't blocked. */ export async function ensureProjectCoolifyProject( projectId: string, workspace: VibnWorkspace, opts: { projectSlug: string; projectName?: string }, ): Promise { const row = await queryOne<{ data: any }>( `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId], ); if (!row) return null; const stored = row.data?.coolifyProjectUuid as string | undefined; if (stored) return stored; const wantName = coolifyProjectNameForVibnProject(workspace.slug, opts.projectSlug); let coolifyUuid: string | null = null; try { // First check if it already exists (could happen if a previous create call // succeeded on Coolify but failed before persisting back to fs_projects). const all = await listCoolifyProjects(); const existing = all.find((p) => p.name === wantName); if (existing) { coolifyUuid = existing.uuid; } else { const created = await createCoolifyProject( wantName, `Vibn project: ${opts.projectName || opts.projectSlug} (workspace: ${workspace.slug})`, ); coolifyUuid = created.uuid; } } catch (err) { console.error( '[projects] Failed to provision Coolify project for', projectId, err instanceof Error ? err.message : String(err), ); // Fall back to the workspace's legacy Coolify project so the user can // still deploy. Lifecycle isolation is degraded but functionality works. coolifyUuid = workspace.coolify_project_uuid; } if (coolifyUuid) { await query( `UPDATE fs_projects SET data = data || jsonb_build_object('coolifyProjectUuid', $2::text), updated_at = NOW() WHERE id = $1`, [projectId, coolifyUuid], ); } return coolifyUuid; } /** * Resolve the Coolify project UUID for a given Vibn project ID, scoped to * the workspace. Returns null if the project doesn't exist or doesn't belong * to the workspace. */ export async function getProjectCoolifyUuid( projectId: string, workspace: VibnWorkspace, ): Promise { const row = await queryOne<{ data: any }>( `SELECT data FROM fs_projects WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`, [projectId, workspace.id, workspace.slug], ); if (!row) return null; return (row.data?.coolifyProjectUuid as string) || null; } /** * Return the COMPLETE set of Coolify project UUIDs owned by this workspace — * the workspace's legacy `vibn-ws-{slug}` project PLUS every per-Vibn-project * Coolify project that has been provisioned. Used by the tenant safety gate * so an API-key principal can touch any of its workspace's resources but * absolutely nothing outside. */ export async function getOwnedCoolifyProjectUuids(workspace: VibnWorkspace): Promise> { const rows = await query<{ uuid: string }>( `SELECT DISTINCT data->>'coolifyProjectUuid' AS uuid FROM fs_projects WHERE (vibn_workspace_id = $1 OR workspace = $2) AND data->>'coolifyProjectUuid' IS NOT NULL`, [workspace.id, workspace.slug], ); const set = new Set(); for (const r of rows) if (r.uuid) set.add(r.uuid); if (workspace.coolify_project_uuid) set.add(workspace.coolify_project_uuid); return set; }