migrate: replace Firebase with PostgreSQL across core routes

- chat-context.ts: session history now from fs_sessions
- /api/sessions: reads from fs_sessions (NextAuth session auth)
- /api/github/connect: NextAuth session + stores in fs_users.data
- /api/user/api-key: NextAuth session + stores in fs_users.data
- /api/projects/[id]/vision: PATCH to fs_projects JSONB
- /api/projects/[id]/knowledge/items: reads from fs_knowledge_items
- /api/projects/[id]/knowledge/import-ai-chat: uses pg createKnowledgeItem
- lib/server/knowledge.ts: fully rewritten to use PostgreSQL
- entrypoint.sh: add fs_knowledge_items and chat_conversations tables

Made-with: Cursor
This commit is contained in:
2026-02-27 13:25:38 -08:00
parent 3ce10dc45b
commit ef7a88e913
9 changed files with 267 additions and 360 deletions

View File

@@ -1,151 +1,96 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin'; import { getServerSession } from 'next-auth';
import { FieldValue } from 'firebase-admin/firestore'; import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
/**
* Store GitHub connection for authenticated user
* Encrypts and stores the access token securely
*/
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const authHeader = request.headers.get('Authorization'); const session = await getServerSession(authOptions);
if (!authHeader?.startsWith('Bearer ')) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { accessToken, githubUser } = await request.json(); const { accessToken, githubUser } = await request.json();
if (!accessToken || !githubUser) { if (!accessToken || !githubUser) {
return NextResponse.json( return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
{ error: 'Missing required fields' },
{ status: 400 }
);
} }
// TODO: Encrypt the access token before storing await query(
// For now, we'll store it directly (should use crypto.subtle or a library) `UPDATE fs_users
const encryptedToken = accessToken; // PLACEHOLDER SET data = data || $1::jsonb, updated_at = NOW()
WHERE data->>'email' = $2`,
// Store GitHub connection [
const connectionRef = adminDb.collection('githubConnections').doc(userId); JSON.stringify({
await connectionRef.set({ githubConnected: true,
userId,
githubUserId: githubUser.id, githubUserId: githubUser.id,
githubUsername: githubUser.login, githubUsername: githubUser.login,
githubName: githubUser.name, githubName: githubUser.name,
githubEmail: githubUser.email, githubEmail: githubUser.email,
githubAvatarUrl: githubUser.avatar_url, githubAvatarUrl: githubUser.avatar_url,
accessToken: encryptedToken, githubAccessToken: accessToken,
connectedAt: FieldValue.serverTimestamp(), githubConnectedAt: new Date().toISOString(),
lastSyncedAt: null, }),
}); session.user.email,
]
);
return NextResponse.json({ return NextResponse.json({ success: true, githubUsername: githubUser.login });
success: true,
githubUsername: githubUser.login,
});
} catch (error) { } catch (error) {
console.error('[GitHub Connect] Error:', error); console.error('[GitHub Connect] Error:', error);
return NextResponse.json( return NextResponse.json({ error: 'Failed to store GitHub connection' }, { status: 500 });
{ error: 'Failed to store GitHub connection' },
{ status: 500 }
);
} }
} }
/**
* Get GitHub connection status for authenticated user
*/
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const authHeader = request.headers.get('Authorization'); const session = await getServerSession(authOptions);
if (!authHeader?.startsWith('Bearer ')) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const idToken = authHeader.split('Bearer ')[1]; const rows = await query<{ data: any }>(
const adminAuth = getAdminAuth(); `SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
const adminDb = getAdminDb(); [session.user.email]
);
let userId: string; if (rows.length === 0 || !rows[0].data?.githubConnected) {
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const connectionDoc = await adminDb
.collection('githubConnections')
.doc(userId)
.get();
if (!connectionDoc.exists) {
return NextResponse.json({ connected: false }); return NextResponse.json({ connected: false });
} }
const data = connectionDoc.data()!; const d = rows[0].data;
return NextResponse.json({ return NextResponse.json({
connected: true, connected: true,
githubUsername: data.githubUsername, githubUsername: d.githubUsername,
githubName: data.githubName, githubName: d.githubName,
githubAvatarUrl: data.githubAvatarUrl, githubAvatarUrl: d.githubAvatarUrl,
connectedAt: data.connectedAt, connectedAt: d.githubConnectedAt,
lastSyncedAt: data.lastSyncedAt,
}); });
} catch (error) { } catch (error) {
console.error('[GitHub Connect] Error:', error); console.error('[GitHub Connect] Error:', error);
return NextResponse.json( return NextResponse.json({ error: 'Failed to fetch GitHub connection' }, { status: 500 });
{ error: 'Failed to fetch GitHub connection' },
{ status: 500 }
);
} }
} }
/**
* Disconnect GitHub account
*/
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const authHeader = request.headers.get('Authorization'); const session = await getServerSession(authOptions);
if (!authHeader?.startsWith('Bearer ')) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const idToken = authHeader.split('Bearer ')[1]; await query(
const adminAuth = getAdminAuth(); `UPDATE fs_users
const adminDb = getAdminDb(); SET data = data - 'githubConnected' - 'githubUserId' - 'githubUsername'
- 'githubName' - 'githubEmail' - 'githubAvatarUrl'
let userId: string; - 'githubAccessToken' - 'githubConnectedAt',
try { updated_at = NOW()
const decodedToken = await adminAuth.verifyIdToken(idToken); WHERE data->>'email' = $1`,
userId = decodedToken.uid; [session.user.email]
} catch (error) { );
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
await adminDb.collection('githubConnections').doc(userId).delete();
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('[GitHub Disconnect] Error:', error); console.error('[GitHub Disconnect] Error:', error);
return NextResponse.json( return NextResponse.json({ error: 'Failed to disconnect GitHub' }, { status: 500 });
{ error: 'Failed to disconnect GitHub' },
{ status: 500 }
);
} }
} }

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
import { createKnowledgeItem } from '@/lib/server/knowledge'; import { createKnowledgeItem } from '@/lib/server/knowledge';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge'; import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
@@ -32,9 +34,12 @@ export async function POST(
return NextResponse.json({ error: 'transcript is required' }, { status: 400 }); return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
} }
const adminDb = getAdminDb(); const session = await getServerSession(authOptions);
const projectSnap = await adminDb.collection('projects').doc(projectId).get(); if (!session?.user?.email) {
if (!projectSnap.exists) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const projectRows = await query(`SELECT id FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId]);
if (projectRows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 }); return NextResponse.json({ error: 'Project not found' }, { status: 404 });
} }

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export async function GET( export async function GET(
request: Request, request: Request,
@@ -8,74 +10,29 @@ export async function GET(
try { try {
const { projectId } = await params; const { projectId } = await params;
// Authentication (skip in development if no auth header) const session = await getServerSession(authOptions);
const authHeader = request.headers.get('Authorization'); if (!session?.user?.email) {
const isDevelopment = process.env.NODE_ENV === 'development';
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const token = authHeader.substring(7); const rows = await query<{ id: string; data: any; created_at: string; updated_at: string }>(
const auth = getAdminAuth(); `SELECT id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100`,
const decoded = await auth.verifyIdToken(token); [projectId]
if (!decoded?.uid) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}
// Fetch knowledge items from Firestore
console.log('[API /knowledge/items] Fetching items for project:', projectId);
let items = [];
try {
const adminDb = getAdminDb();
const knowledgeSnapshot = await adminDb
.collection('knowledge')
.where('projectId', '==', projectId)
.orderBy('createdAt', 'desc')
.limit(100)
.get();
console.log('[API /knowledge/items] Found', knowledgeSnapshot.size, 'items');
items = knowledgeSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
title: data.title || data.content?.substring(0, 50) || 'Untitled',
sourceType: data.sourceType,
content: data.content,
sourceMeta: data.sourceMeta,
createdAt: data.createdAt?.toDate?.()?.toISOString() || data.createdAt,
updatedAt: data.updatedAt?.toDate?.()?.toISOString() || data.updatedAt,
};
});
} catch (firestoreError) {
console.error('[API /knowledge/items] Firestore query failed:', firestoreError);
console.error('[API /knowledge/items] This is likely due to missing Firebase Admin credentials or Firestore not being set up');
// Return empty array instead of failing - the UI will show "No chats yet" and "No images yet"
items = [];
}
return NextResponse.json({
success: true,
items,
count: items.length,
});
} catch (error) {
console.error('[API /knowledge/items] Error fetching knowledge items:', error);
console.error('[API /knowledge/items] Error stack:', error instanceof Error ? error.stack : 'No stack trace');
return NextResponse.json(
{
error: 'Failed to fetch knowledge items',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
); );
}
}
const items = rows.map((row) => ({
id: row.id,
title: row.data?.title || row.data?.content?.substring(0, 50) || 'Untitled',
sourceType: row.data?.sourceType,
content: row.data?.content,
sourceMeta: row.data?.sourceMeta,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
return NextResponse.json({ success: true, items, count: items.length });
} catch (error) {
console.error('[API /knowledge/items] Error:', error);
return NextResponse.json({ success: true, items: [], count: 0 });
}
}

View File

@@ -1,15 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
/**
* Save vision answers to Firestore
*/
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> } { params }: { params: Promise<{ projectId: string }> }
) { ) {
try { try {
const { projectId } = await params; const { projectId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json(); const body = await request.json();
const { visionAnswers } = body; const { visionAnswers } = body;
@@ -20,11 +24,20 @@ export async function POST(
); );
} }
const adminDb = getAdminDb(); const rows = await query<{ id: string; data: any }>(
`SELECT p.id, p.data 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]
);
// Save vision answers and mark ready for MVP if (rows.length === 0) {
await adminDb.collection('projects').doc(projectId).set( return NextResponse.json({ error: 'Project not found' }, { status: 404 });
{ }
const current = rows[0].data ?? {};
const updated = {
...current,
visionAnswers: { visionAnswers: {
q1: visionAnswers.q1, q1: visionAnswers.q1,
q2: visionAnswers.q2, q2: visionAnswers.q2,
@@ -35,14 +48,17 @@ export async function POST(
readyForMVP: true, readyForMVP: true,
currentPhase: 'mvp', currentPhase: 'mvp',
phaseStatus: 'ready', phaseStatus: 'ready',
}, updatedAt: new Date().toISOString(),
{ merge: true } };
await query(
`UPDATE fs_projects SET data = $1::jsonb WHERE id = $2`,
[JSON.stringify(updated), projectId]
); );
console.log(`[Vision API] Saved vision answers for project ${projectId}`); console.log(`[Vision API] Saved vision answers for project ${projectId}`);
// Trigger MVP generation (async - don't wait) // Trigger MVP generation (async - don't wait)
console.log(`[Vision API] Triggering MVP generation for project ${projectId}...`);
fetch(new URL(`/api/projects/${projectId}/mvp-checklist`, request.url).toString(), { fetch(new URL(`/api/projects/${projectId}/mvp-checklist`, request.url).toString(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -65,4 +81,3 @@ export async function POST(
); );
} }
} }

View File

@@ -1,59 +1,72 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const { searchParams } = new URL(request.url); const session = await getServerSession(authOptions);
const projectId = searchParams.get('projectId'); if (!session?.user?.email) {
const limit = parseInt(searchParams.get('limit') || '10'); return NextResponse.json([], { status: 200 });
console.log(`[API] Fetching sessions for project ${projectId}, limit ${limit}`);
const adminDb = getAdminDb();
let sessionsQuery = adminDb.collection('sessions');
// Filter by projectId if provided
if (projectId) {
sessionsQuery = sessionsQuery.where('projectId', '==', projectId) as any;
} }
const sessionsSnapshot = await sessionsQuery const { searchParams } = new URL(request.url);
.orderBy('createdAt', 'desc') const projectId = searchParams.get('projectId');
.limit(limit) const limit = parseInt(searchParams.get('limit') || '50');
.get();
const sessions = sessionsSnapshot.docs.map(doc => { let rows: any[];
const data = doc.data(); if (projectId) {
rows = await query<any>(
`SELECT s.id, s.data, s.created_at
FROM fs_sessions s
JOIN fs_users u ON u.id = s.user_id
WHERE u.data->>'email' = $1 AND s.data->>'projectId' = $2
ORDER BY s.created_at DESC LIMIT $3`,
[session.user.email, projectId, limit]
);
} else {
rows = await query<any>(
`SELECT s.id, s.data, s.created_at
FROM fs_sessions s
JOIN fs_users u ON u.id = s.user_id
WHERE u.data->>'email' = $1
ORDER BY s.created_at DESC LIMIT $2`,
[session.user.email, limit]
);
}
const sessions = rows.map((row: any) => {
const d = row.data ?? {};
return { return {
id: doc.id, id: row.id,
session_id: doc.id, session_id: row.id,
projectId: data.projectId, projectId: d.projectId,
userId: data.userId, userId: d.userId,
workspacePath: data.workspacePath, workspacePath: d.workspacePath,
workspaceName: data.workspaceName, workspaceName: d.workspaceName,
startTime: data.startTime, startTime: d.startTime,
endTime: data.endTime, endTime: d.endTime,
duration: data.duration, duration: d.duration,
duration_minutes: data.duration ? Math.round(data.duration / 60) : 0, duration_minutes: d.duration ? Math.round(d.duration / 60) : 0,
tokensUsed: data.tokensUsed || 0, tokensUsed: d.tokensUsed || 0,
total_tokens: data.tokensUsed || 0, total_tokens: d.tokensUsed || 0,
cost: data.cost || 0, cost: d.cost || 0,
estimated_cost_usd: data.cost || 0, estimated_cost_usd: d.cost || 0,
model: data.model || 'unknown', model: d.model || 'unknown',
primary_ai_model: data.model || 'unknown', primary_ai_model: d.model || 'unknown',
filesModified: data.filesModified || [], filesModified: d.filesModified || [],
summary: data.conversationSummary || null, summary: d.conversationSummary || null,
message_count: data.messageCount || 0, message_count: d.messageCount || 0,
ide_name: 'Cursor', ide_name: 'Cursor',
github_branch: data.githubBranch || null, github_branch: d.githubBranch || null,
conversation: data.conversation || [], conversation: d.conversation || [],
file_changes: data.fileChanges || [], file_changes: d.fileChanges || [],
createdAt: data.createdAt, createdAt: row.created_at,
last_updated: data.updatedAt || data.createdAt, last_updated: d.updatedAt || row.created_at,
}; };
}); });
console.log(`[API] Found ${sessions.length} sessions from Firebase`); console.log(`[API] Found ${sessions.length} sessions from PostgreSQL`);
return NextResponse.json(sessions); return NextResponse.json(sessions);
} catch (error) { } catch (error) {
console.error('[API] Error fetching sessions:', error); console.error('[API] Error fetching sessions:', error);

View File

@@ -1,76 +1,41 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { FieldValue } from 'firebase-admin/firestore';
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
console.log('[API] Getting API key...'); const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'No authorization token provided' }, { status: 401 });
}
// Get the authorization header const rows = await query<{ data: any }>(
const authHeader = request.headers.get('authorization'); `SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
if (!authHeader?.startsWith('Bearer ')) { [session.user.email]
console.error('[API] No authorization header');
return NextResponse.json(
{ error: 'No authorization token provided' },
{ status: 401 }
); );
if (rows.length > 0 && rows[0].data?.apiKey) {
return NextResponse.json({ apiKey: rows[0].data.apiKey });
} }
const token = authHeader.substring(7); // Generate new API key and store it
console.log('[API] Token received, verifying...');
// Verify the Firebase ID token
const decodedToken = await adminAuth.verifyIdToken(token);
const userId = decodedToken.uid;
console.log('[API] Token verified, userId:', userId);
// Check if user already has an API key
console.log('[API] Checking for existing API key...');
const userDoc = await adminDb.collection('users').doc(userId).get();
if (userDoc.exists && userDoc.data()?.apiKey) {
console.log('[API] Found existing API key');
return NextResponse.json({
apiKey: userDoc.data()!.apiKey,
});
}
// Generate new API key
console.log('[API] Generating new API key...');
const apiKey = `vibn_${uuidv4().replace(/-/g, '')}`; const apiKey = `vibn_${uuidv4().replace(/-/g, '')}`;
// Store API key document await query(
console.log('[API] Storing API key in Firestore...'); `UPDATE fs_users
await adminDb.collection('apiKeys').doc(apiKey).set({ SET data = data || $1::jsonb, updated_at = NOW()
key: apiKey, WHERE data->>'email' = $2`,
userId, [JSON.stringify({ apiKey, apiKeyCreatedAt: new Date().toISOString() }), session.user.email]
createdAt: FieldValue.serverTimestamp(), );
isActive: true,
});
// Update user document with API key reference (or create if doesn't exist) return NextResponse.json({ apiKey, isNew: true });
console.log('[API] Updating user document...');
await adminDb.collection('users').doc(userId).set({
apiKey,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
console.log('[API] API key created successfully:', apiKey);
return NextResponse.json({
apiKey,
isNew: true,
});
} catch (error) { } catch (error) {
console.error('[API] Error getting/creating API key:', error); console.error('[API] Error getting/creating API key:', error);
console.error('[API] Error stack:', error instanceof Error ? error.stack : 'No stack trace');
return NextResponse.json( return NextResponse.json(
{ { error: 'Failed to get API key', details: error instanceof Error ? error.message : String(error) },
error: 'Failed to get API key',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@@ -38,6 +38,19 @@ pool.query(\`
); );
CREATE INDEX IF NOT EXISTS idx_fs_sessions_user_id ON fs_sessions (user_id); CREATE INDEX IF NOT EXISTS idx_fs_sessions_user_id ON fs_sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_fs_sessions_project_id ON fs_sessions ((data->>'projectId')); CREATE INDEX IF NOT EXISTS idx_fs_sessions_project_id ON fs_sessions ((data->>'projectId'));
CREATE TABLE IF NOT EXISTS fs_knowledge_items (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
project_id TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fs_knowledge_project_id ON fs_knowledge_items (project_id);
CREATE TABLE IF NOT EXISTS chat_conversations (
project_id TEXT PRIMARY KEY,
messages JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
\`).then(() => { console.log('App tables ready'); pool.end(); }).catch(e => { console.error('Table init error:', e.message); pool.end(); }); \`).then(() => { console.log('App tables ready'); pool.end(); }).catch(e => { console.error('Table init error:', e.message); pool.end(); });
" "

View File

@@ -205,17 +205,15 @@ export async function buildProjectContextForChat(
}; };
try { try {
// Query sessions linked to this project // Query sessions linked to this project from PostgreSQL
const sessionsSnapshot = await adminDb const sessionRows = await query<{ id: string; data: any }>(
.collection('sessions') `SELECT id, data FROM fs_sessions WHERE data->>'projectId' = $1 ORDER BY created_at ASC`,
.where('projectId', '==', projectId) [projectId]
.orderBy('startTime', 'asc') );
.get();
if (!sessionsSnapshot.empty) { if (sessionRows.length > 0) {
sessionHistory.totalSessions = sessionsSnapshot.size; sessionHistory.totalSessions = sessionRows.length;
// Extract all messages from all sessions in chronological order
const allMessages: Array<{ const allMessages: Array<{
role: string; role: string;
content: string; content: string;
@@ -223,32 +221,27 @@ export async function buildProjectContextForChat(
sessionId: string; sessionId: string;
}> = []; }> = [];
for (const sessionDoc of sessionsSnapshot.docs) { for (const row of sessionRows) {
const sessionData = sessionDoc.data(); const conversation = row.data?.conversation || [];
const conversation = sessionData.conversation || [];
// Add messages from this session
for (const msg of conversation) { for (const msg of conversation) {
if (msg.content && msg.content.trim()) { if (msg.content && msg.content.trim()) {
allMessages.push({ allMessages.push({
role: msg.role || 'unknown', role: msg.role || 'unknown',
content: msg.content, content: msg.content,
timestamp: msg.timestamp instanceof Date timestamp: typeof msg.timestamp === 'string'
? msg.timestamp.toISOString() ? msg.timestamp
: (typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString()), : new Date().toISOString(),
sessionId: sessionDoc.id, sessionId: row.id,
}); });
} }
} }
} }
// Sort all messages by timestamp (chronological order)
allMessages.sort((a, b) => allMessages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
); );
sessionHistory.messages = allMessages; sessionHistory.messages = allMessages;
console.log( console.log(
`[Chat Context] Loaded ${sessionHistory.totalSessions} sessions with ${allMessages.length} total messages for project ${projectId}` `[Chat Context] Loaded ${sessionHistory.totalSessions} sessions with ${allMessages.length} total messages for project ${projectId}`
); );

View File

@@ -1,13 +1,11 @@
import { getAdminDb } from '@/lib/firebase/admin'; import { query } from '@/lib/db-postgres';
import { FieldValue } from 'firebase-admin/firestore'; import { randomUUID } from 'crypto';
import type { import type {
KnowledgeItem, KnowledgeItem,
KnowledgeSourceMeta, KnowledgeSourceMeta,
KnowledgeSourceType, KnowledgeSourceType,
} from '@/lib/types/knowledge'; } from '@/lib/types/knowledge';
const COLLECTION = 'knowledge_items';
interface CreateKnowledgeItemInput { interface CreateKnowledgeItemInput {
projectId: string; projectId: string;
sourceType: KnowledgeSourceType; sourceType: KnowledgeSourceType;
@@ -16,59 +14,62 @@ interface CreateKnowledgeItemInput {
sourceMeta?: KnowledgeSourceMeta; sourceMeta?: KnowledgeSourceMeta;
} }
function rowToItem(row: { id: string; project_id: string; data: any; created_at: string; updated_at: string }): KnowledgeItem {
const d = row.data ?? {};
return {
id: row.id,
projectId: row.project_id,
sourceType: d.sourceType,
title: d.title ?? null,
content: d.content,
sourceMeta: d.sourceMeta ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
} as KnowledgeItem;
}
export async function createKnowledgeItem( export async function createKnowledgeItem(
input: CreateKnowledgeItemInput, input: CreateKnowledgeItemInput,
): Promise<KnowledgeItem> { ): Promise<KnowledgeItem> {
const adminDb = getAdminDb(); const id = randomUUID();
const docRef = adminDb.collection(COLLECTION).doc(); const data = {
const payload = {
id: docRef.id,
projectId: input.projectId,
sourceType: input.sourceType, sourceType: input.sourceType,
title: input.title ?? null, title: input.title ?? null,
content: input.content, content: input.content,
sourceMeta: input.sourceMeta ?? null, sourceMeta: input.sourceMeta ?? null,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}; };
await docRef.set(payload); await query(
const snapshot = await docRef.get(); `INSERT INTO fs_knowledge_items (id, project_id, data) VALUES ($1, $2, $3::jsonb)`,
return snapshot.data() as KnowledgeItem; [id, input.projectId, JSON.stringify(data)]
);
const rows = await query<any>(
`SELECT id, project_id, data, created_at, updated_at FROM fs_knowledge_items WHERE id = $1`,
[id]
);
return rowToItem(rows[0]);
} }
export async function getKnowledgeItem( export async function getKnowledgeItem(
projectId: string, projectId: string,
knowledgeItemId: string, knowledgeItemId: string,
): Promise<KnowledgeItem | null> { ): Promise<KnowledgeItem | null> {
const adminDb = getAdminDb(); const rows = await query<any>(
const docRef = adminDb.collection(COLLECTION).doc(knowledgeItemId); `SELECT id, project_id, data, created_at, updated_at FROM fs_knowledge_items WHERE id = $1 AND project_id = $2`,
const snapshot = await docRef.get(); [knowledgeItemId, projectId]
);
if (!snapshot.exists) { if (rows.length === 0) return null;
return null; return rowToItem(rows[0]);
}
const data = snapshot.data() as KnowledgeItem;
if (data.projectId !== projectId) {
return null;
}
return data;
} }
export async function listKnowledgeItems( export async function listKnowledgeItems(
projectId: string, projectId: string,
): Promise<KnowledgeItem[]> { ): Promise<KnowledgeItem[]> {
const adminDb = getAdminDb(); const rows = await query<any>(
const querySnapshot = await adminDb `SELECT id, project_id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC`,
.collection(COLLECTION) [projectId]
.where('projectId', '==', projectId) );
.orderBy('createdAt', 'desc') return rows.map(rowToItem);
.get();
return querySnapshot.docs.map((doc) => doc.data() as KnowledgeItem);
} }