156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
/**
|
|
* 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 { authSession } from '@/lib/auth/session-server';
|
|
import { query } 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 session = await authSession();
|
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
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
|
|
? [session.user.email, workspace, projectId]
|
|
: [session.user.email, workspace];
|
|
|
|
const rows = await query<any>(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 session = await authSession();
|
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
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, session.user.email],
|
|
);
|
|
if (owned.length > 0) safeProjectId = projectId;
|
|
}
|
|
|
|
const rows = await query<any>(
|
|
`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`,
|
|
[
|
|
session.user.email,
|
|
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,
|
|
},
|
|
});
|
|
}
|