/** * User Networking API Routes * * Handles: * - User search by username * - Connection management (follow/unfollow) * - Edge metadata (labels, notes, colors) * - Network graph retrieval */ import { IRequest } from 'itty-router'; import { Environment, UserProfile, UserConnection, ConnectionMetadata, UserNode, GraphEdge, NetworkGraph } from './types'; // ============================================================================= // Helper Functions // ============================================================================= function generateId(): string { return crypto.randomUUID(); } function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' }, }); } function errorResponse(message: string, status = 400): Response { return jsonResponse({ error: message }, status); } // Extract user ID from request (from auth header or session) // For now, we'll use a simple header-based approach function getUserIdFromRequest(request: IRequest): string | null { // Check for X-User-Id header (set by client after CryptID auth) const userId = request.headers.get('X-User-Id'); return userId; } // ============================================================================= // User Search Routes // ============================================================================= /** * GET /api/networking/users/search?q=query&limit=20 * Search users by username or display name */ export async function searchUsers(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const url = new URL(request.url); const query = url.searchParams.get('q') || ''; const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100); if (!query || query.length < 2) { return errorResponse('Query must be at least 2 characters'); } const currentUserId = getUserIdFromRequest(request); try { // Search users by username or display name const searchPattern = `%${query}%`; const users = await db.prepare(` SELECT u.id, u.cryptid_username as username, COALESCE(p.display_name, u.cryptid_username) as displayName, p.avatar_color as avatarColor, p.bio FROM users u LEFT JOIN user_profiles p ON u.id = p.user_id WHERE ( u.cryptid_username LIKE ?1 OR p.display_name LIKE ?1 ) AND (p.is_searchable = 1 OR p.is_searchable IS NULL) LIMIT ?2 `).bind(searchPattern, limit).all(); // If we have a current user, add connection status let results = users.results || []; if (currentUserId && results.length > 0) { const userIds = results.map((u: any) => u.id); // Get connections from current user const myConnections = await db.prepare(` SELECT to_user_id FROM user_connections WHERE from_user_id = ? `).bind(currentUserId).all(); const connectedIds = new Set((myConnections.results || []).map((c: any) => c.to_user_id)); // Get connections to current user const theirConnections = await db.prepare(` SELECT from_user_id FROM user_connections WHERE to_user_id = ? `).bind(currentUserId).all(); const connectedBackIds = new Set((theirConnections.results || []).map((c: any) => c.from_user_id)); results = results.map((user: any) => ({ ...user, isConnected: connectedIds.has(user.id), isConnectedBack: connectedBackIds.has(user.id), mutualConnections: 0, // TODO: Calculate mutual connections })); } return jsonResponse(results); } catch (error) { console.error('User search error:', error); return errorResponse('Search failed', 500); } } /** * GET /api/networking/users/:userId * Get a user's public profile */ export async function getUserProfile(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const { userId } = request.params; try { const result = await db.prepare(` SELECT u.id, u.cryptid_username as username, COALESCE(p.display_name, u.cryptid_username) as displayName, p.avatar_color as avatarColor, p.bio FROM users u LEFT JOIN user_profiles p ON u.id = p.user_id WHERE u.id = ? `).bind(userId).first(); if (!result) { return errorResponse('User not found', 404); } return jsonResponse(result); } catch (error) { console.error('Get profile error:', error); return errorResponse('Failed to get profile', 500); } } /** * PUT /api/networking/users/me * Update current user's profile */ export async function updateMyProfile(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } try { const body = await request.json() as { displayName?: string; bio?: string; avatarColor?: string; }; // Upsert profile await db.prepare(` INSERT INTO user_profiles (user_id, display_name, bio, avatar_color, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET display_name = COALESCE(?2, display_name), bio = COALESCE(?3, bio), avatar_color = COALESCE(?4, avatar_color), updated_at = datetime('now') `).bind(userId, body.displayName || null, body.bio || null, body.avatarColor || null).run(); // Return updated profile return getUserProfile({ ...request, params: { userId } } as IRequest, env); } catch (error) { console.error('Update profile error:', error); return errorResponse('Failed to update profile', 500); } } // ============================================================================= // Connection Management Routes // ============================================================================= /** * POST /api/networking/connections * Create a connection (follow a user) * Body: { toUserId: string, trustLevel?: 'connected' | 'trusted' } */ export async function createConnection(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const fromUserId = getUserIdFromRequest(request); if (!fromUserId) { return errorResponse('Unauthorized', 401); } try { const body = await request.json() as { toUserId: string; trustLevel?: 'connected' | 'trusted' }; const { toUserId, trustLevel = 'connected' } = body; if (!toUserId) { return errorResponse('toUserId is required'); } if (fromUserId === toUserId) { return errorResponse('Cannot connect to yourself'); } if (trustLevel !== 'connected' && trustLevel !== 'trusted') { return errorResponse('trustLevel must be "connected" or "trusted"'); } // Check if target user exists const targetUser = await db.prepare('SELECT id FROM users WHERE id = ?').bind(toUserId).first(); if (!targetUser) { return errorResponse('User not found', 404); } // Check if connection already exists const existing = await db.prepare(` SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ? `).bind(fromUserId, toUserId).first(); if (existing) { return errorResponse('Already connected'); } // Create connection const connectionId = generateId(); await db.prepare(` INSERT INTO user_connections (id, from_user_id, to_user_id, trust_level) VALUES (?, ?, ?, ?) `).bind(connectionId, fromUserId, toUserId, trustLevel).run(); // Check if mutual and get their trust level const reverseConnection = await db.prepare(` SELECT id, trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ? `).bind(toUserId, fromUserId).first() as { id: string; trust_level: string } | null; // Calculate effective trust level (highest of both directions) let effectiveTrustLevel = null; if (reverseConnection) { const theirLevel = reverseConnection.trust_level; effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected'; } const connection = { id: connectionId, fromUserId, toUserId, trustLevel, createdAt: new Date().toISOString(), isMutual: !!reverseConnection, effectiveTrustLevel, }; return jsonResponse(connection, 201); } catch (error) { console.error('Create connection error:', error); return errorResponse('Failed to create connection', 500); } } /** * PUT /api/networking/connections/:connectionId/trust * Update trust level for a connection */ export async function updateConnectionTrust(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } const { connectionId } = request.params; try { // Verify ownership const connection = await db.prepare(` SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ? AND from_user_id = ? `).bind(connectionId, userId).first() as { id: string; from_user_id: string; to_user_id: string } | null; if (!connection) { return errorResponse('Connection not found or not owned by you', 404); } const body = await request.json() as { trustLevel: 'connected' | 'trusted' }; const { trustLevel } = body; if (trustLevel !== 'connected' && trustLevel !== 'trusted') { return errorResponse('trustLevel must be "connected" or "trusted"'); } // Update trust level await db.prepare(` UPDATE user_connections SET trust_level = ?, updated_at = datetime('now') WHERE id = ? `).bind(trustLevel, connectionId).run(); // Check if mutual and get their trust level const reverseConnection = await db.prepare(` SELECT trust_level FROM user_connections WHERE from_user_id = ? AND to_user_id = ? `).bind(connection.to_user_id, connection.from_user_id).first() as { trust_level: string } | null; let effectiveTrustLevel = null; if (reverseConnection) { const theirLevel = reverseConnection.trust_level; effectiveTrustLevel = (trustLevel === 'trusted' || theirLevel === 'trusted') ? 'trusted' : 'connected'; } return jsonResponse({ id: connectionId, fromUserId: connection.from_user_id, toUserId: connection.to_user_id, trustLevel, isMutual: !!reverseConnection, effectiveTrustLevel, }); } catch (error) { console.error('Update trust level error:', error); return errorResponse('Failed to update trust level', 500); } } /** * DELETE /api/networking/connections/:connectionId * Remove a connection (unfollow) */ export async function removeConnection(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } const { connectionId } = request.params; try { // Verify ownership const connection = await db.prepare(` SELECT id FROM user_connections WHERE id = ? AND from_user_id = ? `).bind(connectionId, userId).first(); if (!connection) { return errorResponse('Connection not found or not owned by you', 404); } // Delete connection and its metadata await db.prepare('DELETE FROM connection_metadata WHERE connection_id = ?').bind(connectionId).run(); await db.prepare('DELETE FROM user_connections WHERE id = ?').bind(connectionId).run(); return new Response(null, { status: 204 }); } catch (error) { console.error('Remove connection error:', error); return errorResponse('Failed to remove connection', 500); } } /** * GET /api/networking/connections * Get current user's connections (people they follow) */ export async function getMyConnections(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } try { const connections = await db.prepare(` SELECT c.id, c.from_user_id as fromUserId, c.to_user_id as toUserId, c.created_at as createdAt, m.label, m.notes, m.color, m.strength, EXISTS( SELECT 1 FROM user_connections r WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id ) as isMutual FROM user_connections c LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ? WHERE c.from_user_id = ? `).bind(userId, userId).all(); const results = (connections.results || []).map((c: any) => ({ id: c.id, fromUserId: c.fromUserId, toUserId: c.toUserId, createdAt: c.createdAt, isMutual: !!c.isMutual, metadata: c.label || c.notes || c.color || c.strength ? { label: c.label, notes: c.notes, color: c.color, strength: c.strength || 5, } : undefined, })); return jsonResponse(results); } catch (error) { console.error('Get connections error:', error); return errorResponse('Failed to get connections', 500); } } /** * GET /api/networking/connections/followers * Get users who follow the current user */ export async function getMyFollowers(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } try { const connections = await db.prepare(` SELECT c.id, c.from_user_id as fromUserId, c.to_user_id as toUserId, c.created_at as createdAt, EXISTS( SELECT 1 FROM user_connections r WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id ) as isMutual FROM user_connections c WHERE c.to_user_id = ? `).bind(userId).all(); const results = (connections.results || []).map((c: any) => ({ id: c.id, fromUserId: c.fromUserId, toUserId: c.toUserId, createdAt: c.createdAt, isMutual: !!c.isMutual, })); return jsonResponse(results); } catch (error) { console.error('Get followers error:', error); return errorResponse('Failed to get followers', 500); } } /** * GET /api/networking/connections/check/:userId * Check if current user is connected to a specific user */ export async function checkConnection(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const currentUserId = getUserIdFromRequest(request); if (!currentUserId) { return errorResponse('Unauthorized', 401); } const { userId } = request.params; try { const connection = await db.prepare(` SELECT id FROM user_connections WHERE from_user_id = ? AND to_user_id = ? `).bind(currentUserId, userId).first(); return jsonResponse({ connected: !!connection }); } catch (error) { console.error('Check connection error:', error); return errorResponse('Failed to check connection', 500); } } // ============================================================================= // Edge Metadata Routes // ============================================================================= /** * PUT /api/networking/connections/:connectionId/metadata * Update edge metadata for a connection */ export async function updateEdgeMetadata(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } const { connectionId } = request.params; try { // Verify user is on this connection const connection = await db.prepare(` SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ? `).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null; if (!connection) { return errorResponse('Connection not found', 404); } if (connection.from_user_id !== userId && connection.to_user_id !== userId) { return errorResponse('Not authorized to edit this connection', 403); } const body = await request.json() as { label?: string; notes?: string; color?: string; strength?: number; }; // Validate strength if (body.strength !== undefined && (body.strength < 1 || body.strength > 10)) { return errorResponse('Strength must be between 1 and 10'); } // Upsert metadata const metadataId = generateId(); await db.prepare(` INSERT INTO connection_metadata (id, connection_id, user_id, label, notes, color, strength, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now')) ON CONFLICT(connection_id, user_id) DO UPDATE SET label = COALESCE(?4, label), notes = COALESCE(?5, notes), color = COALESCE(?6, color), strength = COALESCE(?7, strength), updated_at = datetime('now') `).bind( metadataId, connectionId, userId, body.label || null, body.notes || null, body.color || null, body.strength || null ).run(); // Return updated metadata const metadata = await db.prepare(` SELECT label, notes, color, strength FROM connection_metadata WHERE connection_id = ? AND user_id = ? `).bind(connectionId, userId).first(); return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 }); } catch (error) { console.error('Update metadata error:', error); return errorResponse('Failed to update metadata', 500); } } /** * GET /api/networking/connections/:connectionId/metadata * Get edge metadata for a connection */ export async function getEdgeMetadata(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } const { connectionId } = request.params; try { // Verify user is on this connection const connection = await db.prepare(` SELECT id, from_user_id, to_user_id FROM user_connections WHERE id = ? `).bind(connectionId).first() as { id: string; from_user_id: string; to_user_id: string } | null; if (!connection) { return errorResponse('Connection not found', 404); } if (connection.from_user_id !== userId && connection.to_user_id !== userId) { return errorResponse('Not authorized to view this connection', 403); } const metadata = await db.prepare(` SELECT label, notes, color, strength FROM connection_metadata WHERE connection_id = ? AND user_id = ? `).bind(connectionId, userId).first(); return jsonResponse(metadata || { label: null, notes: null, color: null, strength: 5 }); } catch (error) { console.error('Get metadata error:', error); return errorResponse('Failed to get metadata', 500); } } // ============================================================================= // Network Graph Routes // ============================================================================= /** * GET /api/networking/graph * Get the full network graph for current user */ export async function getNetworkGraph(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } try { // Get all users connected to/from current user const connections = await db.prepare(` SELECT DISTINCT user_id FROM ( SELECT to_user_id as user_id FROM user_connections WHERE from_user_id = ? UNION SELECT from_user_id as user_id FROM user_connections WHERE to_user_id = ? ) `).bind(userId, userId).all(); const connectedUserIds = (connections.results || []).map((c: any) => c.user_id); connectedUserIds.push(userId); // Include self // Get user profiles for all connected users const placeholders = connectedUserIds.map(() => '?').join(','); const users = await db.prepare(` SELECT u.id, u.cryptid_username as username, COALESCE(p.display_name, u.cryptid_username) as displayName, p.avatar_color as avatarColor, p.bio FROM users u LEFT JOIN user_profiles p ON u.id = p.user_id WHERE u.id IN (${placeholders}) `).bind(...connectedUserIds).all(); // Build nodes const nodes: UserNode[] = (users.results || []).map((u: any) => ({ id: u.id, username: u.username, displayName: u.displayName, avatarColor: u.avatarColor, bio: u.bio, })); // Get all edges between these users const edges = await db.prepare(` SELECT c.id, c.from_user_id as fromUserId, c.to_user_id as toUserId, c.created_at as createdAt, m.label, m.notes, m.color, m.strength, EXISTS( SELECT 1 FROM user_connections r WHERE r.from_user_id = c.to_user_id AND r.to_user_id = c.from_user_id ) as isMutual FROM user_connections c LEFT JOIN connection_metadata m ON c.id = m.connection_id AND m.user_id = ? WHERE c.from_user_id IN (${placeholders}) AND c.to_user_id IN (${placeholders}) `).bind(userId, ...connectedUserIds, ...connectedUserIds).all(); const graphEdges: GraphEdge[] = (edges.results || []).map((e: any) => ({ id: e.id, fromUserId: e.fromUserId, toUserId: e.toUserId, createdAt: e.createdAt, isMutual: !!e.isMutual, metadata: (e.fromUserId === userId || e.toUserId === userId) && (e.label || e.notes || e.color || e.strength) ? { label: e.label, notes: e.notes, color: e.color, strength: e.strength || 5, } : undefined, })); // Get list of users current user is connected to const myConnections = await db.prepare(` SELECT to_user_id FROM user_connections WHERE from_user_id = ? `).bind(userId).all(); const graph: NetworkGraph = { nodes, edges: graphEdges, myConnections: (myConnections.results || []).map((c: any) => c.to_user_id), }; return jsonResponse(graph); } catch (error) { console.error('Get network graph error:', error); return errorResponse('Failed to get network graph', 500); } } /** * POST /api/networking/graph/room * Get network graph scoped to room participants * Body: { participants: string[] } */ export async function getRoomNetworkGraph(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const userId = getUserIdFromRequest(request); if (!userId) { return errorResponse('Unauthorized', 401); } try { const body = await request.json() as { participants: string[] }; const { participants } = body; if (!participants || !Array.isArray(participants)) { return errorResponse('participants array is required'); } // First get the full network graph const graphResponse = await getNetworkGraph(request, env); const graph = await graphResponse.json() as NetworkGraph; // Mark which nodes are in the room const participantSet = new Set(participants); const nodesWithRoomStatus = graph.nodes.map(node => ({ ...node, isInRoom: participantSet.has(node.id), })); return jsonResponse({ ...graph, nodes: nodesWithRoomStatus, roomParticipants: participants, }); } catch (error) { console.error('Get room network graph error:', error); return errorResponse('Failed to get room network graph', 500); } } /** * GET /api/networking/connections/mutual/:userId * Get mutual connections between current user and another user */ export async function getMutualConnections(request: IRequest, env: Environment): Promise { const db = env.CRYPTID_DB; if (!db) { return errorResponse('Database not configured', 500); } const currentUserId = getUserIdFromRequest(request); if (!currentUserId) { return errorResponse('Unauthorized', 401); } const { userId } = request.params; try { // Find users that both current user and target user are connected to const mutuals = await db.prepare(` SELECT u.id, u.cryptid_username as username, COALESCE(p.display_name, u.cryptid_username) as displayName, p.avatar_color as avatarColor, p.bio FROM users u LEFT JOIN user_profiles p ON u.id = p.user_id WHERE u.id IN ( SELECT c1.to_user_id FROM user_connections c1 INNER JOIN user_connections c2 ON c1.to_user_id = c2.to_user_id WHERE c1.from_user_id = ? AND c2.from_user_id = ? ) `).bind(currentUserId, userId).all(); return jsonResponse(mutuals.results || []); } catch (error) { console.error('Get mutual connections error:', error); return errorResponse('Failed to get mutual connections', 500); } }