import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { RichText, Agent } from '@atproto/api'; import { RecordId } from 'surrealdb'; import { connectToDB } from '@/lib/db'; import { generateEmbedding } from '@/lib/ai'; import { verifySurrealJwt } from '@/lib/auth/jwt'; import { getOAuthClient } from '@/lib/auth/oauth-client'; export async function POST(request: NextRequest) { const cookieStore = await cookies(); const surrealJwt = cookieStore.get('ponderants-auth')?.value; console.log('[POST /api/nodes] Auth check:', { hasSurrealJwt: !!surrealJwt, }); if (!surrealJwt) { console.error('[POST /api/nodes] Missing auth cookie'); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } // Verify the JWT and extract user info const userSession = verifySurrealJwt(surrealJwt); if (!userSession) { console.error('[POST /api/nodes] Invalid JWT'); return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 }); } const { did: userDid } = userSession; console.log('[POST /api/nodes] Verified user DID:', userDid); const { title, body, links } = (await request.json()) as { title: string; body: string; links?: string[]; // Array of at-uri strings }; if (!title || !body) { return NextResponse.json({ error: 'Title and body are required' }, { status: 400 }); } const createdAt = new Date().toISOString(); // Generate a unique node ID upfront (we'll use this for the detail page link) const nodeUuid = crypto.randomUUID(); const nodeId = `node:${nodeUuid}`; const detailUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://ponderants.app'}/galaxy/${encodeURIComponent(nodeId)}`; // --- Step 1: Write to Source of Truth (ATproto) --- let atp_uri = ''; let atp_cid = ''; try { // Get the OAuth client and restore the user's session const client = await getOAuthClient(); console.log('[POST /api/nodes] Got OAuth client, attempting to restore session for DID:', userDid); // Restore the session - returns an OAuthSession object directly const session = await client.restore(userDid); // Create an Agent from the session const agent = new Agent(session); console.log('[POST /api/nodes] Successfully restored OAuth session and created agent'); // Bluesky posts are limited to 300 graphemes // We'll create a thread for longer content const fullText = `${title}\n\n${body}`; // Helper to count graphemes using RichText function getGraphemeLength(text: string): number { const rt = new RichText({ text }); return rt.graphemeLength; } // Reserve space for the detail page link in first post const linkSuffix = `\n\nRead more: ${detailUrl}`; const linkGraphemes = getGraphemeLength(linkSuffix); // For first post, we need room for the content + link const firstPostMaxGraphemes = 300 - linkGraphemes - 5; // 5 char buffer // For subsequent thread posts, we need room for the thread indicator like "(2/3) " const threadIndicatorMaxGraphemes = 10; // Max graphemes for "(99/99) " const threadPostMaxGraphemes = 300 - threadIndicatorMaxGraphemes - 5; // 5 char buffer // Helper function to split text into chunks at word boundaries using grapheme counting function splitIntoChunks(text: string, firstMaxGraphemes: number, otherMaxGraphemes: number): string[] { const chunks: string[] = []; let remainingText = text; let isFirst = true; while (remainingText.length > 0) { const maxGraphemes = isFirst ? firstMaxGraphemes : otherMaxGraphemes; const graphemeCount = getGraphemeLength(remainingText); if (graphemeCount <= maxGraphemes) { chunks.push(remainingText); break; } // Find last space within maxGraphemes let testText = remainingText; // Binary search for the right split point while (getGraphemeLength(testText) > maxGraphemes) { const lastSpace = testText.lastIndexOf(' '); if (lastSpace === -1 || lastSpace < testText.length * 0.5) { // No good space found, just hard cut at character boundary // Start from the end and work backwards testText = testText.substring(0, Math.floor(testText.length * 0.9)); } else { testText = testText.substring(0, lastSpace); } } chunks.push(testText.trim()); remainingText = remainingText.substring(testText.length).trim(); isFirst = false; } return chunks; } let chunks: string[]; if (getGraphemeLength(fullText) <= firstPostMaxGraphemes) { // Single post chunks = [fullText]; } else { // Split into thread, accounting for link on first post and thread indicators on others chunks = splitIntoChunks(fullText, firstPostMaxGraphemes, threadPostMaxGraphemes); } // Create the thread posts let previousPost: { uri: string; cid: string } | null = null; let rootPost: { uri: string; cid: string } | null = null; const threadUris: string[] = []; for (let i = 0; i < chunks.length; i++) { const isFirstPost = i === 0; let postText = chunks[i]; // Add thread indicator if not first/last post if (chunks.length > 1 && !isFirstPost) { postText = `(${i + 1}/${chunks.length}) ${postText}`; } // Add detail page link to first post if (isFirstPost) { postText += linkSuffix; } // Validate grapheme count before posting const finalGraphemes = getGraphemeLength(postText); if (finalGraphemes > 300) { console.error(`[POST /api/nodes] Post ${i + 1} exceeds 300 graphemes (${finalGraphemes})`); throw new Error(`Post exceeds 300 grapheme limit: ${finalGraphemes} graphemes`); } // Format the text as RichText to detect links, mentions, etc. const rt = new RichText({ text: postText }); await rt.detectFacets(agent); // Prepare the post record const postRecord: any = { $type: 'app.bsky.feed.post', text: rt.text, facets: rt.facets, createdAt: new Date().toISOString(), tags: ['ponderants-node'], }; // If not first post, add reply reference to create a thread if (previousPost && rootPost) { postRecord.reply = { root: { uri: rootPost.uri, cid: rootPost.cid }, parent: { uri: previousPost.uri, cid: previousPost.cid }, }; } // Create the post const response = await agent.api.com.atproto.repo.createRecord({ repo: userDid, collection: 'app.bsky.feed.post', record: postRecord, }); threadUris.push(response.data.uri); previousPost = { uri: response.data.uri, cid: response.data.cid }; // Store the first post's URI and CID as both the root and the main atp_uri if (isFirstPost) { rootPost = { uri: response.data.uri, cid: response.data.cid }; atp_uri = response.data.uri; atp_cid = response.data.cid; } } console.log('[POST /api/nodes] ✓ Published to ATproto PDS as standard post:', atp_uri); } catch (error) { console.error('[POST /api/nodes] ATproto write error:', error); return NextResponse.json({ error: 'Failed to publish to PDS' }, { status: 500 }); } // --- Step 2: Generate AI Embedding (Cache) --- // Embeddings are optional - used for vector search and 3D visualization let embedding: number[] | undefined; try { embedding = await generateEmbedding(title + '\n' + body); console.log('[POST /api/nodes] ✓ Generated embedding vector'); } catch (error) { console.warn('[POST /api/nodes] ⚠ Embedding generation failed (non-critical):', error); // Continue without embedding - it's only needed for advanced features embedding = undefined; } // --- Step 3: Write to App View Cache (SurrealDB) --- // The cache is optional - the ATproto PDS is the source of truth try { const db = await connectToDB(); // Create the node record in our cache. // The `user_did` field is set, satisfying the 'PERMISSIONS' // clause defined in the schema. const nodeData: any = { user_did: userDid, atp_uri: atp_uri, title: title, body: body, // Store the raw text body coords_3d: [0, 0, 0], // Required by schema - origin coordinates for new nodes }; // Only include embedding if it was successfully generated if (embedding) { nodeData.embedding = embedding; } // Use RecordId object to specify table and ID separately // SurrealDB client expects RecordId object, not 'table:id' string const newNode = await db.create(new RecordId('node', nodeUuid), nodeData); // Handle linking if (links && links.length > 0) { // Find the corresponding cache nodes for the AT-URIs const targetNodesResult = await db.query<[Array<{ id: string }>]>( 'SELECT id FROM node WHERE user_did = $did AND atp_uri IN $links', { did: userDid, links: links } ); const targetNodes = targetNodesResult[0] || []; // Create graph relations for (const targetNode of targetNodes) { await db.query('RELATE $from->links_to->$to', { from: (newNode as any)[0].id, to: targetNode.id, }); } } console.log('[POST /api/nodes] ✓ Cached node in SurrealDB'); return NextResponse.json({ success: true, atp_uri, node: newNode }); } catch (error) { console.warn('[POST /api/nodes] ⚠ SurrealDB cache write failed (non-critical):', error); // The node was successfully published to ATproto (source of truth) // Cache failure is non-critical - advanced features may be unavailable return NextResponse.json({ success: true, atp_uri, warning: 'Node published to Bluesky, but cache update failed. Advanced features may be unavailable.', }); } }