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

View File

@@ -1,5 +1,7 @@
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 type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
@@ -32,9 +34,12 @@ export async function POST(
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
}
const adminDb = getAdminDb();
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
if (!projectSnap.exists) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
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 });
}

View File

@@ -1,5 +1,7 @@
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(
request: Request,
@@ -8,74 +10,29 @@ export async function GET(
try {
const { projectId } = await params;
// Authentication (skip in development if no auth header)
const authHeader = request.headers.get('Authorization');
const isDevelopment = process.env.NODE_ENV === 'development';
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
if (!authHeader?.startsWith('Bearer ')) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = getAdminAuth();
const decoded = await auth.verifyIdToken(token);
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 rows = await query<{ id: string; data: any; created_at: string; updated_at: string }>(
`SELECT id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100`,
[projectId]
);
}
}
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 { 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(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
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 { 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
await adminDb.collection('projects').doc(projectId).set(
{
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const current = rows[0].data ?? {};
const updated = {
...current,
visionAnswers: {
q1: visionAnswers.q1,
q2: visionAnswers.q2,
@@ -35,14 +48,17 @@ export async function POST(
readyForMVP: true,
currentPhase: 'mvp',
phaseStatus: 'ready',
},
{ merge: true }
updatedAt: new Date().toISOString(),
};
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}`);
// 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(), {
method: 'POST',
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 { 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) {
try {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId');
const limit = parseInt(searchParams.get('limit') || '10');
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 session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json([], { status: 200 });
}
const sessionsSnapshot = await sessionsQuery
.orderBy('createdAt', 'desc')
.limit(limit)
.get();
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId');
const limit = parseInt(searchParams.get('limit') || '50');
const sessions = sessionsSnapshot.docs.map(doc => {
const data = doc.data();
let rows: any[];
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 {
id: doc.id,
session_id: doc.id,
projectId: data.projectId,
userId: data.userId,
workspacePath: data.workspacePath,
workspaceName: data.workspaceName,
startTime: data.startTime,
endTime: data.endTime,
duration: data.duration,
duration_minutes: data.duration ? Math.round(data.duration / 60) : 0,
tokensUsed: data.tokensUsed || 0,
total_tokens: data.tokensUsed || 0,
cost: data.cost || 0,
estimated_cost_usd: data.cost || 0,
model: data.model || 'unknown',
primary_ai_model: data.model || 'unknown',
filesModified: data.filesModified || [],
summary: data.conversationSummary || null,
message_count: data.messageCount || 0,
id: row.id,
session_id: row.id,
projectId: d.projectId,
userId: d.userId,
workspacePath: d.workspacePath,
workspaceName: d.workspaceName,
startTime: d.startTime,
endTime: d.endTime,
duration: d.duration,
duration_minutes: d.duration ? Math.round(d.duration / 60) : 0,
tokensUsed: d.tokensUsed || 0,
total_tokens: d.tokensUsed || 0,
cost: d.cost || 0,
estimated_cost_usd: d.cost || 0,
model: d.model || 'unknown',
primary_ai_model: d.model || 'unknown',
filesModified: d.filesModified || [],
summary: d.conversationSummary || null,
message_count: d.messageCount || 0,
ide_name: 'Cursor',
github_branch: data.githubBranch || null,
conversation: data.conversation || [],
file_changes: data.fileChanges || [],
createdAt: data.createdAt,
last_updated: data.updatedAt || data.createdAt,
github_branch: d.githubBranch || null,
conversation: d.conversation || [],
file_changes: d.fileChanges || [],
createdAt: row.created_at,
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);
} catch (error) {
console.error('[API] Error fetching sessions:', error);

View File

@@ -1,76 +1,41 @@
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 { FieldValue } from 'firebase-admin/firestore';
export async function GET(request: Request) {
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 authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
console.error('[API] No authorization header');
return NextResponse.json(
{ error: 'No authorization token provided' },
{ status: 401 }
const rows = await query<{ data: any }>(
`SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[session.user.email]
);
if (rows.length > 0 && rows[0].data?.apiKey) {
return NextResponse.json({ apiKey: rows[0].data.apiKey });
}
const token = authHeader.substring(7);
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...');
// Generate new API key and store it
const apiKey = `vibn_${uuidv4().replace(/-/g, '')}`;
// Store API key document
console.log('[API] Storing API key in Firestore...');
await adminDb.collection('apiKeys').doc(apiKey).set({
key: apiKey,
userId,
createdAt: FieldValue.serverTimestamp(),
isActive: true,
});
await query(
`UPDATE fs_users
SET data = data || $1::jsonb, updated_at = NOW()
WHERE data->>'email' = $2`,
[JSON.stringify({ apiKey, apiKeyCreatedAt: new Date().toISOString() }), session.user.email]
);
// Update user document with API key reference (or create if doesn't exist)
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,
});
return NextResponse.json({ apiKey, isNew: true });
} catch (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(
{
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 }
);
}
}

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_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(); });
"

View File

@@ -205,17 +205,15 @@ export async function buildProjectContextForChat(
};
try {
// Query sessions linked to this project
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('projectId', '==', projectId)
.orderBy('startTime', 'asc')
.get();
// Query sessions linked to this project from PostgreSQL
const sessionRows = await query<{ id: string; data: any }>(
`SELECT id, data FROM fs_sessions WHERE data->>'projectId' = $1 ORDER BY created_at ASC`,
[projectId]
);
if (!sessionsSnapshot.empty) {
sessionHistory.totalSessions = sessionsSnapshot.size;
if (sessionRows.length > 0) {
sessionHistory.totalSessions = sessionRows.length;
// Extract all messages from all sessions in chronological order
const allMessages: Array<{
role: string;
content: string;
@@ -223,32 +221,27 @@ export async function buildProjectContextForChat(
sessionId: string;
}> = [];
for (const sessionDoc of sessionsSnapshot.docs) {
const sessionData = sessionDoc.data();
const conversation = sessionData.conversation || [];
// Add messages from this session
for (const row of sessionRows) {
const conversation = row.data?.conversation || [];
for (const msg of conversation) {
if (msg.content && msg.content.trim()) {
allMessages.push({
role: msg.role || 'unknown',
content: msg.content,
timestamp: msg.timestamp instanceof Date
? msg.timestamp.toISOString()
: (typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString()),
sessionId: sessionDoc.id,
timestamp: typeof msg.timestamp === 'string'
? msg.timestamp
: new Date().toISOString(),
sessionId: row.id,
});
}
}
}
// Sort all messages by timestamp (chronological order)
allMessages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
sessionHistory.messages = allMessages;
console.log(
`[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 { FieldValue } from 'firebase-admin/firestore';
import { query } from '@/lib/db-postgres';
import { randomUUID } from 'crypto';
import type {
KnowledgeItem,
KnowledgeSourceMeta,
KnowledgeSourceType,
} from '@/lib/types/knowledge';
const COLLECTION = 'knowledge_items';
interface CreateKnowledgeItemInput {
projectId: string;
sourceType: KnowledgeSourceType;
@@ -16,59 +14,62 @@ interface CreateKnowledgeItemInput {
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(
input: CreateKnowledgeItemInput,
): Promise<KnowledgeItem> {
const adminDb = getAdminDb();
const docRef = adminDb.collection(COLLECTION).doc();
const payload = {
id: docRef.id,
projectId: input.projectId,
const id = randomUUID();
const data = {
sourceType: input.sourceType,
title: input.title ?? null,
content: input.content,
sourceMeta: input.sourceMeta ?? null,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
};
await docRef.set(payload);
const snapshot = await docRef.get();
return snapshot.data() as KnowledgeItem;
await query(
`INSERT INTO fs_knowledge_items (id, project_id, data) VALUES ($1, $2, $3::jsonb)`,
[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(
projectId: string,
knowledgeItemId: string,
): Promise<KnowledgeItem | null> {
const adminDb = getAdminDb();
const docRef = adminDb.collection(COLLECTION).doc(knowledgeItemId);
const snapshot = await docRef.get();
if (!snapshot.exists) {
return null;
}
const data = snapshot.data() as KnowledgeItem;
if (data.projectId !== projectId) {
return null;
}
return data;
const rows = await query<any>(
`SELECT id, project_id, data, created_at, updated_at FROM fs_knowledge_items WHERE id = $1 AND project_id = $2`,
[knowledgeItemId, projectId]
);
if (rows.length === 0) return null;
return rowToItem(rows[0]);
}
export async function listKnowledgeItems(
projectId: string,
): Promise<KnowledgeItem[]> {
const adminDb = getAdminDb();
const querySnapshot = await adminDb
.collection(COLLECTION)
.where('projectId', '==', projectId)
.orderBy('createdAt', 'desc')
.get();
return querySnapshot.docs.map((doc) => doc.data() as KnowledgeItem);
const rows = await query<any>(
`SELECT id, project_id, data, created_at, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC`,
[projectId]
);
return rows.map(rowToItem);
}