/** * Vibn workspaces — logical multi-tenancy on top of Coolify + Gitea. * * Each Vibn user gets one workspace. The workspace owns: * - a Coolify Project UUID (the team/namespace boundary inside Coolify) * - a Gitea org (which contains all repos for the workspace) * * All Vibn projects, apps, deployments, and AI access keys are * scoped to a single workspace. Code that touches Coolify or Gitea * MUST resolve a workspace first and use its IDs (never the legacy * hardcoded admin user / project UUID). * * Coolify cannot create real Teams via its public API today — see * Coolify changelog notes about scoping queries to current team and * the lack of POST /teams. We treat one Coolify *Project* as our * tenant boundary instead, and stamp `coolify_team_id` later if/when * Coolify exposes team creation. */ import { query, queryOne } from '@/lib/db-postgres'; import { createProject as createCoolifyProject } from '@/lib/coolify'; import { createOrg, getOrg, getUser, addOrgOwner } from '@/lib/gitea'; export interface VibnWorkspace { id: string; slug: string; name: string; owner_user_id: string; coolify_project_uuid: string | null; coolify_team_id: number | null; gitea_org: string | null; provision_status: 'pending' | 'partial' | 'ready' | 'error'; provision_error: string | null; created_at: Date; updated_at: Date; } export interface VibnWorkspaceMember { id: string; workspace_id: string; user_id: string; role: 'owner' | 'admin' | 'member'; created_at: Date; } // ────────────────────────────────────────────────── // Slug helpers // ────────────────────────────────────────────────── const SAFE_SLUG = /[^a-z0-9]+/g; export function workspaceSlugFromEmail(email: string): string { const local = email.split('@')[0]?.toLowerCase() ?? 'user'; return local.replace(SAFE_SLUG, '-').replace(/^-+|-+$/g, '') || 'user'; } /** Coolify Project name we use for a workspace. Prefixed to avoid collisions. */ export function coolifyProjectNameFor(slug: string): string { return `vibn-ws-${slug}`; } /** Gitea org name we use for a workspace. Same prefix for consistency. */ export function giteaOrgNameFor(slug: string): string { return `vibn-${slug}`; } // ────────────────────────────────────────────────── // CRUD // ────────────────────────────────────────────────── export async function getWorkspaceById(id: string): Promise { return queryOne(`SELECT * FROM vibn_workspaces WHERE id = $1`, [id]); } export async function getWorkspaceBySlug(slug: string): Promise { return queryOne(`SELECT * FROM vibn_workspaces WHERE slug = $1`, [slug]); } export async function getWorkspaceByOwner(userId: string): Promise { return queryOne( `SELECT * FROM vibn_workspaces WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1`, [userId] ); } export async function listWorkspacesForUser(userId: string): Promise { return query( `SELECT w.* FROM vibn_workspaces w LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id WHERE w.owner_user_id = $1 OR m.user_id = $1 GROUP BY w.id ORDER BY w.created_at ASC`, [userId] ); } export async function userHasWorkspaceAccess(userId: string, workspaceId: string): Promise { const row = await queryOne<{ id: string }>( `SELECT w.id FROM vibn_workspaces w LEFT JOIN vibn_workspace_members m ON m.workspace_id = w.id AND m.user_id = $1 WHERE w.id = $2 AND (w.owner_user_id = $1 OR m.user_id = $1) LIMIT 1`, [userId, workspaceId] ); return !!row; } // ────────────────────────────────────────────────── // Get-or-create + provision // ────────────────────────────────────────────────── /** * Idempotently ensures a workspace row exists for the user. Does NOT * provision Coolify/Gitea — call ensureWorkspaceProvisioned() for that. * * Suitable to call from the NextAuth signIn callback (cheap, single insert). */ export async function ensureWorkspaceForUser(opts: { userId: string; email: string; displayName?: string | null; }): Promise { const existing = await getWorkspaceByOwner(opts.userId); if (existing) return existing; const slug = await pickAvailableSlug(workspaceSlugFromEmail(opts.email)); const name = opts.displayName?.trim() || opts.email.split('@')[0]; const inserted = await query( `INSERT INTO vibn_workspaces (slug, name, owner_user_id) VALUES ($1, $2, $3) RETURNING *`, [slug, name, opts.userId] ); const workspace = inserted[0]; await query( `INSERT INTO vibn_workspace_members (workspace_id, user_id, role) VALUES ($1, $2, 'owner') ON CONFLICT (workspace_id, user_id) DO NOTHING`, [workspace.id, opts.userId] ); return workspace; } /** * Provisions Coolify Project + Gitea org for a workspace if not yet done. * Idempotent. Failures are recorded on the row but do not throw — callers * can retry by calling again. Returns the up-to-date workspace row. */ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Promise { if (workspace.provision_status === 'ready') return workspace; let coolifyUuid = workspace.coolify_project_uuid; let giteaOrg = workspace.gitea_org; const errors: string[] = []; // ── Coolify Project ──────────────────────────────────────────────── if (!coolifyUuid) { try { const project = await createCoolifyProject( coolifyProjectNameFor(workspace.slug), `Vibn workspace ${workspace.slug}` ); coolifyUuid = project.uuid; } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Coolify returns 400/409 if the name collides — fall through; the // workspace can still be patched manually with the right UUID. errors.push(`coolify: ${msg}`); console.error('[workspaces] Coolify provisioning failed', workspace.slug, msg); } } // ── Gitea Org ────────────────────────────────────────────────────── if (!giteaOrg) { const wantOrg = giteaOrgNameFor(workspace.slug); try { const existingOrg = await getOrg(wantOrg); if (existingOrg) { giteaOrg = existingOrg.username; } else { const created = await createOrg({ name: wantOrg, fullName: workspace.name, description: `Vibn workspace for ${workspace.slug}`, visibility: 'private', }); giteaOrg = created.username; } } catch (err) { const msg = err instanceof Error ? err.message : String(err); errors.push(`gitea: ${msg}`); console.error('[workspaces] Gitea org provisioning failed', workspace.slug, msg); } } // ── Add the workspace owner to the Gitea org if they have a Gitea account. // Best-effort: most Vibn users won't have a Gitea login, so a 404 is fine. if (giteaOrg) { try { const ownerEmail = await queryOne<{ email: string }>( `SELECT data->>'email' AS email FROM fs_users WHERE id = $1`, [workspace.owner_user_id] ); const candidateLogin = ownerEmail?.email ? workspaceSlugFromEmail(ownerEmail.email) : null; if (candidateLogin) { const giteaUser = await getUser(candidateLogin); if (giteaUser) { await addOrgOwner(giteaOrg, giteaUser.login); } } } catch (err) { // Membership add is best-effort console.warn('[workspaces] Skipping Gitea owner add', err); } } const status: VibnWorkspace['provision_status'] = coolifyUuid && giteaOrg ? 'ready' : errors.length > 0 ? 'partial' : 'pending'; const updated = await query( `UPDATE vibn_workspaces SET coolify_project_uuid = COALESCE($2, coolify_project_uuid), gitea_org = COALESCE($3, gitea_org), provision_status = $4, provision_error = $5, updated_at = now() WHERE id = $1 RETURNING *`, [workspace.id, coolifyUuid, giteaOrg, status, errors.length ? errors.join('; ') : null] ); return updated[0]; } /** * Convenience: get-or-create + provision in one call. Used by the * project-create flow so the first project in a fresh account always * has somewhere to land. */ export async function getOrCreateProvisionedWorkspace(opts: { userId: string; email: string; displayName?: string | null; }): Promise { const ws = await ensureWorkspaceForUser(opts); return ensureWorkspaceProvisioned(ws); } // ────────────────────────────────────────────────── // Slug uniqueness // ────────────────────────────────────────────────── async function pickAvailableSlug(base: string): Promise { // Try base, then base-2, base-3, … up to base-99. for (let i = 0; i < 100; i++) { const candidate = i === 0 ? base : `${base}-${i + 1}`; const existing = await queryOne<{ id: string }>( `SELECT id FROM vibn_workspaces WHERE slug = $1 LIMIT 1`, [candidate] ); if (!existing) return candidate; } // Extremely unlikely fallback return `${base}-${Date.now().toString(36)}`; }