From 28b48b74afeeb648be565663efff0ce1a8cbf70b Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Sat, 7 Mar 2026 12:16:16 -0800 Subject: [PATCH] fix: surface agent_sessions 500 and add db migration - sessions/route.ts: replace inline CREATE TABLE DDL with a lightweight existence check; add `details` to all 500 responses; fix type-unsafe `p.id = $1::uuid` comparisons to `p.id::text = $1` to avoid the Postgres `text = uuid` operator error - app/api/admin/migrate: one-shot idempotent migration endpoint secured with ADMIN_MIGRATE_SECRET, creates fs_* tables + agent_sessions - scripts/migrate-fs-tables.sql: formal schema for all fs_* tables Made-with: Cursor --- app/api/admin/migrate/route.ts | 149 ++++++++++++++++++ .../[projectId]/agent/sessions/route.ts | 44 +++--- scripts/migrate-fs-tables.sql | 140 ++++++++++++++++ 3 files changed, 307 insertions(+), 26 deletions(-) create mode 100644 app/api/admin/migrate/route.ts create mode 100644 scripts/migrate-fs-tables.sql diff --git a/app/api/admin/migrate/route.ts b/app/api/admin/migrate/route.ts new file mode 100644 index 0000000..f9578bd --- /dev/null +++ b/app/api/admin/migrate/route.ts @@ -0,0 +1,149 @@ +/** + * POST /api/admin/migrate + * + * One-shot migration endpoint. Requires the ADMIN_MIGRATE_SECRET env var + * to be set and passed as x-admin-secret header (or ?secret= query param). + * + * Idempotent — safe to call multiple times (all statements use IF NOT EXISTS). + * + * curl -X POST https://vibnai.com/api/admin/migrate \ + * -H "x-admin-secret: " + */ +import { NextRequest, NextResponse } from "next/server"; +import { query } from "@/lib/db-postgres"; +import { readFileSync } from "fs"; +import { join } from "path"; + +export async function POST(req: NextRequest) { + const secret = process.env.ADMIN_MIGRATE_SECRET ?? ""; + if (!secret) { + return NextResponse.json( + { error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" }, + { status: 403 } + ); + } + + const incoming = + req.headers.get("x-admin-secret") ?? + new URL(req.url).searchParams.get("secret") ?? + ""; + + if (incoming !== secret) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const results: Array<{ statement: string; ok: boolean; error?: string }> = []; + + // Inline the DDL so this works even if the SQL file isn't on the runtime fs + const statements = [ + `CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`, + + `CREATE TABLE IF NOT EXISTS fs_users ( + id TEXT PRIMARY KEY, + user_id TEXT, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS fs_users_email_idx ON fs_users ((data->>'email'))`, + `CREATE INDEX IF NOT EXISTS fs_users_user_id_idx ON fs_users (user_id)`, + + `CREATE TABLE IF NOT EXISTS fs_projects ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + workspace TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS fs_projects_user_idx ON fs_projects (user_id)`, + `CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx ON fs_projects (workspace)`, + + `CREATE TABLE IF NOT EXISTS fs_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS fs_sessions_user_idx ON fs_sessions (user_id)`, + `CREATE INDEX IF NOT EXISTS fs_sessions_project_idx ON fs_sessions ((data->>'projectId'))`, + + // agent_sessions uses TEXT for project_id to match fs_projects.id + `CREATE TABLE IF NOT EXISTS agent_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, + app_name TEXT NOT NULL, + app_path TEXT NOT NULL, + task TEXT NOT NULL, + plan JSONB, + status TEXT NOT NULL DEFAULT 'pending', + output JSONB NOT NULL DEFAULT '[]', + changed_files JSONB NOT NULL DEFAULT '[]', + error TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC)`, + `CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status)`, + + // NextAuth / Prisma tables + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT, + email TEXT UNIQUE, + email_verified TIMESTAMPTZ, + image TEXT + )`, + `CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + refresh_token TEXT, + access_token TEXT, + expires_at INTEGER, + token_type TEXT, + scope TEXT, + id_token TEXT, + session_state TEXT, + UNIQUE (provider, provider_account_id) + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + session_token TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires TIMESTAMPTZ NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS verification_tokens ( + identifier TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires TIMESTAMPTZ NOT NULL, + UNIQUE (identifier, token) + )`, + ]; + + for (const stmt of statements) { + const label = stmt.trim().split("\n")[0].trim().slice(0, 80); + try { + await query(stmt, []); + results.push({ statement: label, ok: true }); + } catch (err) { + results.push({ + statement: label, + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const failed = results.filter(r => !r.ok); + return NextResponse.json( + { ok: failed.length === 0, results }, + { status: failed.length === 0 ? 200 : 207 } + ); +} diff --git a/app/api/projects/[projectId]/agent/sessions/route.ts b/app/api/projects/[projectId]/agent/sessions/route.ts index 87a31fa..5eecaed 100644 --- a/app/api/projects/[projectId]/agent/sessions/route.ts +++ b/app/api/projects/[projectId]/agent/sessions/route.ts @@ -15,28 +15,14 @@ import { query } from "@/lib/db-postgres"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; -// Ensure the agent_sessions table exists (idempotent). +// Verify the agent_sessions table is reachable. If it doesn't exist yet, +// throw a descriptive error instead of a generic "Failed to create session". +// Run POST /api/admin/migrate once to create the table. async function ensureTable() { - await query(` - CREATE TABLE IF NOT EXISTS agent_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID NOT NULL, - app_name TEXT NOT NULL, - app_path TEXT NOT NULL, - task TEXT NOT NULL, - plan JSONB, - status TEXT NOT NULL DEFAULT 'pending', - output JSONB NOT NULL DEFAULT '[]'::jsonb, - changed_files JSONB NOT NULL DEFAULT '[]'::jsonb, - error TEXT, - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - CREATE INDEX IF NOT EXISTS agent_sessions_project_idx - ON agent_sessions(project_id, created_at DESC); - `, []); + await query( + `SELECT 1 FROM agent_sessions LIMIT 0`, + [] + ); } // ── POST — create session ──────────────────────────────────────────────────── @@ -69,7 +55,7 @@ export async function POST( const owns = await query<{ id: string; data: Record }>( `SELECT p.id, p.data FROM fs_projects p JOIN fs_users u ON u.id = p.user_id - WHERE p.id = $1::uuid AND u.data->>'email' = $2 LIMIT 1`, + WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`, [projectId, session.user.email] ); if (owns.length === 0) { @@ -122,7 +108,10 @@ export async function POST( return NextResponse.json({ sessionId }, { status: 201 }); } catch (err) { console.error("[agent/sessions POST]", err); - return NextResponse.json({ error: "Failed to create session" }, { status: 500 }); + return NextResponse.json( + { error: "Failed to create session", details: err instanceof Error ? err.message : String(err) }, + { status: 500 } + ); } } @@ -157,9 +146,9 @@ export async function GET( s.created_at, s.started_at, s.completed_at, s.output, s.changed_files, s.error FROM agent_sessions s - JOIN fs_projects p ON p.id = s.project_id + JOIN fs_projects p ON p.id::text = s.project_id::text JOIN fs_users u ON u.id = p.user_id - WHERE s.project_id = $1::uuid AND u.data->>'email' = $2 + WHERE s.project_id::text = $1 AND u.data->>'email' = $2 ORDER BY s.created_at DESC LIMIT 50`, [projectId, session.user.email] @@ -168,6 +157,9 @@ export async function GET( return NextResponse.json({ sessions }); } catch (err) { console.error("[agent/sessions GET]", err); - return NextResponse.json({ error: "Failed to list sessions" }, { status: 500 }); + return NextResponse.json( + { error: "Failed to list sessions", details: err instanceof Error ? err.message : String(err) }, + { status: 500 } + ); } } diff --git a/scripts/migrate-fs-tables.sql b/scripts/migrate-fs-tables.sql new file mode 100644 index 0000000..e69a2a1 --- /dev/null +++ b/scripts/migrate-fs-tables.sql @@ -0,0 +1,140 @@ +-- ============================================================================= +-- VIBN fs_* tables + agent_sessions migration +-- Run once against the production Coolify Postgres database. +-- +-- These tables back the live app (fs_ prefix = "Firestore-shaped" flexible +-- JSONB rows that replaced the original Firebase collections). +-- +-- Safe to re-run — all statements use IF NOT EXISTS / ON CONFLICT. +-- ============================================================================= + +-- Enable uuid support (safe no-op if already enabled) +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- --------------------------------------------------------------------------- +-- fs_users (mirrors Firebase Auth + Firestore user docs) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS fs_users ( + id TEXT PRIMARY KEY, -- gen_random_uuid()::text at insert time + user_id TEXT, -- NextAuth User.id (cuid) + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS fs_users_email_idx + ON fs_users ((data->>'email')); + +CREATE INDEX IF NOT EXISTS fs_users_user_id_idx + ON fs_users (user_id); + +-- --------------------------------------------------------------------------- +-- fs_projects (Firestore projects collection) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS fs_projects ( + id TEXT PRIMARY KEY, -- randomUUID() at insert time + user_id TEXT NOT NULL, -- FK → fs_users.id + workspace TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS fs_projects_user_idx + ON fs_projects (user_id); + +CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx + ON fs_projects (workspace); + +CREATE INDEX IF NOT EXISTS fs_projects_slug_idx + ON fs_projects (slug); + +-- --------------------------------------------------------------------------- +-- fs_sessions (AI coding session logs) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS fs_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS fs_sessions_user_idx + ON fs_sessions (user_id); + +CREATE INDEX IF NOT EXISTS fs_sessions_project_idx + ON fs_sessions ((data->>'projectId')); + +-- --------------------------------------------------------------------------- +-- agent_sessions (vibn-agent-runner execution records) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS agent_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id TEXT NOT NULL, -- fs_projects.id (TEXT) + app_name TEXT NOT NULL, + app_path TEXT NOT NULL, + task TEXT NOT NULL, + plan JSONB, + status TEXT NOT NULL DEFAULT 'pending', + output JSONB NOT NULL DEFAULT '[]'::jsonb, + changed_files JSONB NOT NULL DEFAULT '[]'::jsonb, + error TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agent_sessions_project_idx + ON agent_sessions (project_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS agent_sessions_status_idx + ON agent_sessions (status); + +-- --------------------------------------------------------------------------- +-- NextAuth / Prisma tables (required by PrismaAdapter + strategy:"database") +-- Only created if not already present from a prisma migrate run. +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT, + email TEXT UNIQUE, + email_verified TIMESTAMPTZ, + image TEXT +); + +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + refresh_token TEXT, + access_token TEXT, + expires_at INTEGER, + token_type TEXT, + scope TEXT, + id_token TEXT, + session_state TEXT, + UNIQUE (provider, provider_account_id) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + session_token TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS verification_tokens ( + identifier TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires TIMESTAMPTZ NOT NULL, + UNIQUE (identifier, token) +); + +-- Done +SELECT 'Migration complete' AS status;