/** * GET /api/chat/threads — list user's threads * GET /api/chat/threads?projectId=… — list user's threads for one project * POST /api/chat/threads — create a new thread (optionally scoped to a project) * * Threads can be either: * - workspace-level (project_id NULL) — created from /projects, etc. * - project-scoped (project_id set) — created from a project page so * the AI can pin the right project context in its system prompt. * * The schema is migrated idempotently the first time the route is hit * after deploy (no manual migration needed). */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { query, queryOne } from '@/lib/db-postgres'; let chatTablesReady = false; async function ensureChatTables() { if (chatTablesReady) return; await query(` CREATE TABLE IF NOT EXISTS fs_chat_threads ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, user_id TEXT NOT NULL, workspace TEXT NOT NULL DEFAULT '', 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_chat_threads_user_ws_idx ON fs_chat_threads (user_id, workspace, updated_at DESC); CREATE TABLE IF NOT EXISTS fs_chat_messages ( id BIGSERIAL PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES fs_chat_threads(id) ON DELETE CASCADE, user_id TEXT NOT NULL, data JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS fs_chat_messages_thread_idx ON fs_chat_messages (thread_id, created_at ASC); `, []); // Idempotent migration: add project_id + composite index for fast // per-project listing. No-op on subsequent boots. await query(` ALTER TABLE fs_chat_threads ADD COLUMN IF NOT EXISTS project_id TEXT; CREATE INDEX IF NOT EXISTS fs_chat_threads_user_proj_idx ON fs_chat_threads (user_id, project_id, updated_at DESC); `, []); chatTablesReady = true; } export async function GET(request: Request) { await ensureChatTables(); const principal = await requireWorkspacePrincipal(request); if (principal instanceof NextResponse) return principal; const userRow = await queryOne<{ data: { email?: string } }>( `SELECT data FROM fs_users WHERE id = $1 LIMIT 1`, [principal.userId] ); if (!userRow?.data?.email) return NextResponse.json({ error: 'Unauthorized user' }, { status: 401 }); const sessionEmail = userRow.data.email; const { searchParams } = new URL(request.url); const workspace = searchParams.get('workspace') || ''; const projectId = searchParams.get('projectId') || null; // When projectId is supplied, narrow to that project. When omitted, // return only WORKSPACE-level threads (project_id IS NULL) so the // workspace chat UI doesn't get spammed with every project's history. // LEFT JOIN against fs_chat_messages so each thread row carries a // message count. The Sessions tab on the Plan page renders this so // the user can tell at a glance which conversations were substantive // ("12 messages") vs. abandoned ("1 message"). const sql = projectId ? `SELECT t.id, t.project_id, t.data, t.created_at, t.updated_at, COALESCE(m.cnt, 0)::int AS message_count FROM fs_chat_threads t LEFT JOIN ( SELECT thread_id, COUNT(*) AS cnt FROM fs_chat_messages GROUP BY thread_id ) m ON m.thread_id = t.id WHERE t.user_id = $1 AND t.workspace = $2 AND t.project_id = $3 ORDER BY t.updated_at DESC LIMIT 50` : `SELECT t.id, t.project_id, t.data, t.created_at, t.updated_at, COALESCE(m.cnt, 0)::int AS message_count FROM fs_chat_threads t LEFT JOIN ( SELECT thread_id, COUNT(*) AS cnt FROM fs_chat_messages GROUP BY thread_id ) m ON m.thread_id = t.id WHERE t.user_id = $1 AND t.workspace = $2 AND t.project_id IS NULL ORDER BY t.updated_at DESC LIMIT 50`; const args = projectId ? [sessionEmail, workspace, projectId] : [sessionEmail, workspace]; const rows = await query(sql, args); return NextResponse.json({ threads: rows.map((r: any) => ({ id: r.id, projectId: r.project_id ?? null, title: r.data?.title || 'New conversation', summary: r.data?.summary || null, messageCount: r.message_count ?? 0, updatedAt: r.updated_at, createdAt: r.created_at, })), }); } export async function POST(request: Request) { await ensureChatTables(); const principal = await requireWorkspacePrincipal(request); if (principal instanceof NextResponse) return principal; const userRow = await queryOne<{ data: { email?: string } }>( `SELECT data FROM fs_users WHERE id = $1 LIMIT 1`, [principal.userId] ); if (!userRow?.data?.email) return NextResponse.json({ error: 'Unauthorized user' }, { status: 401 }); const sessionEmail = userRow.data.email; const { workspace, title, projectId } = await request.json().catch(() => ({})); if (!workspace) return NextResponse.json({ error: 'workspace required' }, { status: 400 }); // Verify the project belongs to the requesting user before tagging // a thread to it. Silently drop the projectId if the check fails so // a misbehaving client can't tag threads onto someone else's project. let safeProjectId: string | null = null; if (projectId) { const owned = await query<{ id: string }>( `SELECT p.id 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, sessionEmail], ); if (owned.length > 0) safeProjectId = projectId; } const rows = await query( `INSERT INTO fs_chat_threads (user_id, workspace, project_id, data) VALUES ($1, $2, $3, $4) RETURNING id, project_id, data, created_at, updated_at`, [ sessionEmail, workspace, safeProjectId, JSON.stringify({ title: title || 'New conversation', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }), ], ); const r = rows[0]; return NextResponse.json({ thread: { id: r.id, projectId: r.project_id ?? null, title: r.data?.title || 'New conversation', createdAt: r.created_at, updatedAt: r.updated_at, }, }); }