ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

big ol' backend refactor to separate concerns

authored by byarielm.fyi and committed by byarielm.fyi beb87bbd 0dc7e015

verified
+2 -2
dist/index.html
··· 21 21 ATLast: Sync Your TikTok Follows → ATmosphere (Skylight, Bluesky, 22 22 etc.) 23 23 </title> 24 - <script type="module" crossorigin src="/assets/index-Dx_AzG_Q.js"></script> 25 - <link rel="stylesheet" crossorigin href="/assets/index-C69tQ_4S.css"> 24 + <script type="module" crossorigin src="/assets/index-BmU3Lkw-.js"></script> 25 + <link rel="stylesheet" crossorigin href="/assets/index-DQCpc624.css"> 26 26 </head> 27 27 <body> 28 28 <div id="root"></div>
+6 -1
netlify.toml
··· 4 4 publish = "dist" 5 5 6 6 [[redirects]] 7 + from = "/oauth-client-metadata.json" 8 + to = "/.netlify/functions/client-metadata" 9 + status = 200 10 + 11 + [[redirects]] 7 12 from = "/oauth/callback" 8 13 to = "/.netlify/functions/oauth-callback" 9 14 status = 200 10 15 11 16 [[headers]] 12 - for = "/client-metadata.json" 17 + for = "/oauth-client-metadata.json" 13 18 [headers.values] 14 19 Access-Control-Allow-Origin = "*" 15 20 Cache-Control = "public, max-age=3600"
+131 -206
netlify/functions/batch-follow-users.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { SessionManager } from "./session-manager"; 3 - import { getDbClient } from "./db"; 4 - import cookie from "cookie"; 1 + import { AuthenticatedHandler } from "./shared/types"; 2 + import { SessionService } from "./shared/services/session"; 3 + import { MatchRepository } from "./shared/repositories"; 4 + import { successResponse } from "./shared/utils"; 5 + import { withAuthErrorHandling } from "./shared/middleware"; 6 + import { ValidationError } from "./shared/constants/errors"; 5 7 6 - export const handler: Handler = async ( 7 - event: HandlerEvent, 8 - ): Promise<HandlerResponse> => { 9 - // Only allow POST 10 - if (event.httpMethod !== "POST") { 11 - return { 12 - statusCode: 405, 13 - headers: { "Content-Type": "application/json" }, 14 - body: JSON.stringify({ error: "Method not allowed" }), 15 - }; 8 + const batchFollowHandler: AuthenticatedHandler = async (context) => { 9 + // Parse request body 10 + const body = JSON.parse(context.event.body || "{}"); 11 + const dids: string[] = body.dids || []; 12 + const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; 13 + 14 + if (!Array.isArray(dids) || dids.length === 0) { 15 + throw new ValidationError("dids array is required and must not be empty"); 16 16 } 17 17 18 - try { 19 - // Parse request body 20 - const body = JSON.parse(event.body || "{}"); 21 - const dids: string[] = body.dids || []; 22 - const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; 18 + // Limit batch size to prevent timeouts and respect rate limits 19 + if (dids.length > 100) { 20 + throw new ValidationError("Maximum 100 DIDs per batch"); 21 + } 23 22 24 - if (!Array.isArray(dids) || dids.length === 0) { 25 - return { 26 - statusCode: 400, 27 - headers: { "Content-Type": "application/json" }, 28 - body: JSON.stringify({ 29 - error: "dids array is required and must not be empty", 30 - }), 31 - }; 32 - } 23 + // Get authenticated agent using SessionService 24 + const { agent } = await SessionService.getAgentForSession(context.sessionId); 33 25 34 - // Limit batch size to prevent timeouts and respect rate limits 35 - if (dids.length > 100) { 36 - return { 37 - statusCode: 400, 38 - headers: { "Content-Type": "application/json" }, 39 - body: JSON.stringify({ error: "Maximum 100 DIDs per batch" }), 40 - }; 41 - } 26 + // Check existing follows before attempting to follow 27 + const alreadyFollowing = new Set<string>(); 28 + try { 29 + let cursor: string | undefined = undefined; 30 + let hasMore = true; 31 + const didsSet = new Set(dids); 42 32 43 - // Get session from cookie 44 - const cookies = event.headers.cookie 45 - ? cookie.parse(event.headers.cookie) 46 - : {}; 47 - const sessionId = cookies.atlast_session; 33 + while (hasMore && didsSet.size > 0) { 34 + const response = await agent.api.com.atproto.repo.listRecords({ 35 + repo: context.did, 36 + collection: followLexicon, 37 + limit: 100, 38 + cursor, 39 + }); 48 40 49 - if (!sessionId) { 50 - return { 51 - statusCode: 401, 52 - headers: { "Content-Type": "application/json" }, 53 - body: JSON.stringify({ error: "No session cookie" }), 54 - }; 55 - } 56 - 57 - // Get authenticated agent using SessionManager 58 - const { agent, did: userDid } = 59 - await SessionManager.getAgentForSession(sessionId); 60 - 61 - // Check existing follows before attempting to follow 62 - const alreadyFollowing = new Set<string>(); 63 - try { 64 - let cursor: string | undefined = undefined; 65 - let hasMore = true; 66 - const didsSet = new Set(dids); 67 - 68 - while (hasMore && didsSet.size > 0) { 69 - const response = await agent.api.com.atproto.repo.listRecords({ 70 - repo: userDid, 71 - collection: followLexicon, 72 - limit: 100, 73 - cursor, 74 - }); 75 - 76 - for (const record of response.data.records) { 77 - const followRecord = record.value as any; 78 - if (followRecord?.subject && didsSet.has(followRecord.subject)) { 79 - alreadyFollowing.add(followRecord.subject); 80 - didsSet.delete(followRecord.subject); 81 - } 41 + for (const record of response.data.records) { 42 + const followRecord = record.value as any; 43 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 44 + alreadyFollowing.add(followRecord.subject); 45 + didsSet.delete(followRecord.subject); 82 46 } 47 + } 83 48 84 - cursor = response.data.cursor; 85 - hasMore = !!cursor; 49 + cursor = response.data.cursor; 50 + hasMore = !!cursor; 86 51 87 - if (didsSet.size === 0) { 88 - break; 89 - } 52 + if (didsSet.size === 0) { 53 + break; 90 54 } 91 - } catch (error) { 92 - console.error("Error checking existing follows:", error); 93 - // Continue - we'll handle duplicates in the follow loop 94 55 } 56 + } catch (error) { 57 + console.error("Error checking existing follows:", error); 58 + // Continue - we'll handle duplicates in the follow loop 59 + } 95 60 96 - // Follow all users 97 - const results = []; 98 - let consecutiveErrors = 0; 99 - const MAX_CONSECUTIVE_ERRORS = 3; 100 - const sql = getDbClient(); 61 + // Follow all users 62 + const results = []; 63 + let consecutiveErrors = 0; 64 + const MAX_CONSECUTIVE_ERRORS = 3; 65 + const matchRepo = new MatchRepository(); 101 66 102 - for (const did of dids) { 103 - // Skip if already following 104 - if (alreadyFollowing.has(did)) { 105 - results.push({ 106 - did, 107 - success: true, 108 - alreadyFollowing: true, 109 - error: null, 110 - }); 67 + for (const did of dids) { 68 + // Skip if already following 69 + if (alreadyFollowing.has(did)) { 70 + results.push({ 71 + did, 72 + success: true, 73 + alreadyFollowing: true, 74 + error: null, 75 + }); 111 76 112 - // Update database follow status 113 - try { 114 - await sql` 115 - UPDATE atproto_matches 116 - SET follow_status = follow_status || jsonb_build_object(${followLexicon}, true), 117 - last_follow_check = NOW() 118 - WHERE atproto_did = ${did} 119 - `; 120 - } catch (dbError) { 121 - console.error("Failed to update follow status in DB:", dbError); 122 - } 77 + // Update database follow status 78 + try { 79 + await matchRepo.updateFollowStatus(did, followLexicon, true); 80 + } catch (dbError) { 81 + console.error("Failed to update follow status in DB:", dbError); 82 + } 123 83 124 - continue; 125 - } 84 + continue; 85 + } 126 86 127 - try { 128 - await agent.api.com.atproto.repo.createRecord({ 129 - repo: userDid, 130 - collection: followLexicon, 131 - record: { 132 - $type: followLexicon, 133 - subject: did, 134 - createdAt: new Date().toISOString(), 135 - }, 136 - }); 87 + try { 88 + await agent.api.com.atproto.repo.createRecord({ 89 + repo: context.did, 90 + collection: followLexicon, 91 + record: { 92 + $type: followLexicon, 93 + subject: did, 94 + createdAt: new Date().toISOString(), 95 + }, 96 + }); 137 97 138 - results.push({ 139 - did, 140 - success: true, 141 - alreadyFollowing: false, 142 - error: null, 143 - }); 98 + results.push({ 99 + did, 100 + success: true, 101 + alreadyFollowing: false, 102 + error: null, 103 + }); 144 104 145 - // Update database follow status 146 - try { 147 - await sql` 148 - UPDATE atproto_matches 149 - SET follow_status = follow_status || jsonb_build_object(${followLexicon}, true), 150 - last_follow_check = NOW() 151 - WHERE atproto_did = ${did} 152 - `; 153 - } catch (dbError) { 154 - console.error("Failed to update follow status in DB:", dbError); 155 - } 105 + // Update database follow status 106 + try { 107 + await matchRepo.updateFollowStatus(did, followLexicon, true); 108 + } catch (dbError) { 109 + console.error("Failed to update follow status in DB:", dbError); 110 + } 156 111 157 - // Reset error counter on success 158 - consecutiveErrors = 0; 159 - } catch (error) { 160 - consecutiveErrors++; 112 + // Reset error counter on success 113 + consecutiveErrors = 0; 114 + } catch (error) { 115 + consecutiveErrors++; 161 116 162 - results.push({ 163 - did, 164 - success: false, 165 - alreadyFollowing: false, 166 - error: error instanceof Error ? error.message : "Follow failed", 167 - }); 117 + results.push({ 118 + did, 119 + success: false, 120 + alreadyFollowing: false, 121 + error: error instanceof Error ? error.message : "Follow failed", 122 + }); 168 123 169 - // If we hit rate limits, implement exponential backoff 170 - if ( 171 - error instanceof Error && 172 - (error.message.includes("rate limit") || 173 - error.message.includes("429")) 174 - ) { 175 - const backoffDelay = Math.min( 176 - 200 * Math.pow(2, consecutiveErrors), 177 - 2000, 178 - ); 179 - console.log(`Rate limit hit. Backing off for ${backoffDelay}ms...`); 180 - await new Promise((resolve) => setTimeout(resolve, backoffDelay)); 181 - } else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 182 - // For other repeated errors, small backoff 183 - await new Promise((resolve) => setTimeout(resolve, 500)); 184 - } 124 + // If we hit rate limits, implement exponential backoff 125 + if ( 126 + error instanceof Error && 127 + (error.message.includes("rate limit") || error.message.includes("429")) 128 + ) { 129 + const backoffDelay = Math.min( 130 + 200 * Math.pow(2, consecutiveErrors), 131 + 2000, 132 + ); 133 + console.log(`Rate limit hit. Backing off for ${backoffDelay}ms...`); 134 + await new Promise((resolve) => setTimeout(resolve, backoffDelay)); 135 + } else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 136 + // For other repeated errors, small backoff 137 + await new Promise((resolve) => setTimeout(resolve, 500)); 185 138 } 186 139 } 187 - 188 - const successCount = results.filter((r) => r.success).length; 189 - const failCount = results.filter((r) => !r.success).length; 190 - const alreadyFollowingCount = results.filter( 191 - (r) => r.alreadyFollowing, 192 - ).length; 193 - 194 - return { 195 - statusCode: 200, 196 - headers: { 197 - "Content-Type": "application/json", 198 - "Access-Control-Allow-Origin": "*", 199 - }, 200 - body: JSON.stringify({ 201 - success: true, 202 - total: dids.length, 203 - succeeded: successCount, 204 - failed: failCount, 205 - alreadyFollowing: alreadyFollowingCount, 206 - results, 207 - }), 208 - }; 209 - } catch (error) { 210 - console.error("Batch follow error:", error); 140 + } 211 141 212 - // Handle authentication errors specifically 213 - if (error instanceof Error && error.message.includes("session")) { 214 - return { 215 - statusCode: 401, 216 - headers: { "Content-Type": "application/json" }, 217 - body: JSON.stringify({ 218 - error: "Invalid or expired session", 219 - details: error.message, 220 - }), 221 - }; 222 - } 142 + const successCount = results.filter((r) => r.success).length; 143 + const failCount = results.filter((r) => !r.success).length; 144 + const alreadyFollowingCount = results.filter( 145 + (r) => r.alreadyFollowing, 146 + ).length; 223 147 224 - return { 225 - statusCode: 500, 226 - headers: { "Content-Type": "application/json" }, 227 - body: JSON.stringify({ 228 - error: "Failed to follow users", 229 - details: error instanceof Error ? error.message : "Unknown error", 230 - }), 231 - }; 232 - } 148 + return successResponse({ 149 + success: true, 150 + total: dids.length, 151 + succeeded: successCount, 152 + failed: failCount, 153 + alreadyFollowing: alreadyFollowingCount, 154 + results, 155 + }); 233 156 }; 157 + 158 + export const handler = withAuthErrorHandling(batchFollowHandler);
+146 -203
netlify/functions/batch-search-actors.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { SessionManager } from "./session-manager"; 3 - import cookie from "cookie"; 1 + import { AuthenticatedHandler } from "./shared/types"; 2 + import { SessionService } from "./shared/services/session"; 3 + import { successResponse } from "./shared/utils"; 4 + import { withAuthErrorHandling } from "./shared/middleware"; 5 + import { ValidationError } from "./shared/constants/errors"; 4 6 5 - export const handler: Handler = async ( 6 - event: HandlerEvent, 7 - ): Promise<HandlerResponse> => { 8 - try { 9 - // Parse batch request 10 - const body = JSON.parse(event.body || "{}"); 11 - const usernames: string[] = body.usernames || []; 7 + const batchSearchHandler: AuthenticatedHandler = async (context) => { 8 + // Parse batch request 9 + const body = JSON.parse(context.event.body || "{}"); 10 + const usernames: string[] = body.usernames || []; 12 11 13 - if (!Array.isArray(usernames) || usernames.length === 0) { 12 + if (!Array.isArray(usernames) || usernames.length === 0) { 13 + throw new ValidationError( 14 + "usernames array is required and must not be empty", 15 + ); 16 + } 17 + 18 + // Limit batch size to prevent timeouts 19 + if (usernames.length > 50) { 20 + throw new ValidationError("Maximum 50 usernames per batch"); 21 + } 22 + 23 + // Get authenticated agent using SessionService 24 + const { agent } = await SessionService.getAgentForSession(context.sessionId); 25 + 26 + // Search all usernames in parallel 27 + const searchPromises = usernames.map(async (username) => { 28 + try { 29 + const response = await agent.app.bsky.actor.searchActors({ 30 + q: username, 31 + limit: 20, 32 + }); 33 + 34 + // Filter and rank matches 35 + const normalize = (s: string) => s.toLowerCase().replace(/[._-]/g, ""); 36 + const normalizedUsername = normalize(username); 37 + 38 + const rankedActors = response.data.actors 39 + .map((actor: any) => { 40 + const handlePart = actor.handle.split(".")[0]; 41 + const normalizedHandle = normalize(handlePart); 42 + const normalizedFullHandle = normalize(actor.handle); 43 + const normalizedDisplayName = normalize(actor.displayName || ""); 44 + 45 + let score = 0; 46 + if (normalizedHandle === normalizedUsername) score = 100; 47 + else if (normalizedFullHandle === normalizedUsername) score = 90; 48 + else if (normalizedDisplayName === normalizedUsername) score = 80; 49 + else if (normalizedHandle.includes(normalizedUsername)) score = 60; 50 + else if (normalizedFullHandle.includes(normalizedUsername)) 51 + score = 50; 52 + else if (normalizedDisplayName.includes(normalizedUsername)) 53 + score = 40; 54 + else if (normalizedUsername.includes(normalizedHandle)) score = 30; 55 + 56 + return { 57 + ...actor, 58 + matchScore: score, 59 + did: actor.did, 60 + }; 61 + }) 62 + .filter((actor: any) => actor.matchScore > 0) 63 + .sort((a: any, b: any) => b.matchScore - a.matchScore) 64 + .slice(0, 5); 65 + 14 66 return { 15 - statusCode: 400, 16 - headers: { "Content-Type": "application/json" }, 17 - body: JSON.stringify({ 18 - error: "usernames array is required and must not be empty", 19 - }), 67 + username, 68 + actors: rankedActors, 69 + error: null, 20 70 }; 21 - } 22 - 23 - // Limit batch size to prevent timeouts 24 - if (usernames.length > 50) { 71 + } catch (error) { 25 72 return { 26 - statusCode: 400, 27 - headers: { "Content-Type": "application/json" }, 28 - body: JSON.stringify({ error: "Maximum 50 usernames per batch" }), 73 + username, 74 + actors: [], 75 + error: error instanceof Error ? error.message : "Search failed", 29 76 }; 30 77 } 78 + }); 31 79 32 - // Get session from cookie 33 - const cookies = event.headers.cookie 34 - ? cookie.parse(event.headers.cookie) 35 - : {}; 36 - const sessionId = cookies.atlast_session; 80 + const results = await Promise.all(searchPromises); 37 81 38 - if (!sessionId) { 39 - return { 40 - statusCode: 401, 41 - headers: { "Content-Type": "application/json" }, 42 - body: JSON.stringify({ error: "No session cookie" }), 43 - }; 44 - } 82 + // Enrich results with follower and post counts using getProfiles 83 + const allDids = results 84 + .flatMap((r) => r.actors.map((a: any) => a.did)) 85 + .filter((did): did is string => !!did); 45 86 46 - // Get authenticated agent using SessionManager 47 - const { agent } = await SessionManager.getAgentForSession(sessionId); 87 + if (allDids.length > 0) { 88 + // Create a map to store enriched profile data 89 + const profileDataMap = new Map< 90 + string, 91 + { postCount: number; followerCount: number } 92 + >(); 48 93 49 - // Search all usernames in parallel 50 - const searchPromises = usernames.map(async (username) => { 94 + // Batch fetch profiles (25 at a time - API limit) 95 + const PROFILE_BATCH_SIZE = 25; 96 + for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) { 97 + const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE); 51 98 try { 52 - const response = await agent.app.bsky.actor.searchActors({ 53 - q: username, 54 - limit: 20, 99 + const profilesResponse = await agent.app.bsky.actor.getProfiles({ 100 + actors: batch, 55 101 }); 56 102 57 - // Filter and rank matches (same logic as before) 58 - const normalize = (s: string) => s.toLowerCase().replace(/[._-]/g, ""); 59 - const normalizedUsername = normalize(username); 103 + profilesResponse.data.profiles.forEach((profile: any) => { 104 + profileDataMap.set(profile.did, { 105 + postCount: profile.postsCount || 0, 106 + followerCount: profile.followersCount || 0, 107 + }); 108 + }); 109 + } catch (error) { 110 + console.error("Failed to fetch profile batch:", error); 111 + // Continue even if one batch fails 112 + } 113 + } 60 114 61 - const rankedActors = response.data.actors 62 - .map((actor: any) => { 63 - const handlePart = actor.handle.split(".")[0]; 64 - const normalizedHandle = normalize(handlePart); 65 - const normalizedFullHandle = normalize(actor.handle); 66 - const normalizedDisplayName = normalize(actor.displayName || ""); 67 - 68 - let score = 0; 69 - if (normalizedHandle === normalizedUsername) score = 100; 70 - else if (normalizedFullHandle === normalizedUsername) score = 90; 71 - else if (normalizedDisplayName === normalizedUsername) score = 80; 72 - else if (normalizedHandle.includes(normalizedUsername)) score = 60; 73 - else if (normalizedFullHandle.includes(normalizedUsername)) 74 - score = 50; 75 - else if (normalizedDisplayName.includes(normalizedUsername)) 76 - score = 40; 77 - else if (normalizedUsername.includes(normalizedHandle)) score = 30; 78 - 79 - return { 80 - ...actor, 81 - matchScore: score, 82 - did: actor.did, 83 - }; 84 - }) 85 - .filter((actor: any) => actor.matchScore > 0) 86 - .sort((a: any, b: any) => b.matchScore - a.matchScore) 87 - .slice(0, 5); 88 - 115 + // Merge enriched data back into results 116 + results.forEach((result) => { 117 + result.actors = result.actors.map((actor: any) => { 118 + const enrichedData = profileDataMap.get(actor.did); 89 119 return { 90 - username, 91 - actors: rankedActors, 92 - error: null, 93 - }; 94 - } catch (error) { 95 - return { 96 - username, 97 - actors: [], 98 - error: error instanceof Error ? error.message : "Search failed", 120 + ...actor, 121 + postCount: enrichedData?.postCount || 0, 122 + followerCount: enrichedData?.followerCount || 0, 99 123 }; 100 - } 124 + }); 101 125 }); 126 + } 102 127 103 - const results = await Promise.all(searchPromises); 104 - 105 - // Enrich results with follower and post counts using getProfiles 106 - const allDids = results 107 - .flatMap((r) => r.actors.map((a: any) => a.did)) 108 - .filter((did): did is string => !!did); 128 + // Check follow status for all matched DIDs in chosen lexicon 129 + const followLexicon = body.followLexicon || "app.bsky.graph.follow"; 109 130 110 - if (allDids.length > 0) { 111 - // Create a map to store enriched profile data 112 - const profileDataMap = new Map< 113 - string, 114 - { postCount: number; followerCount: number } 115 - >(); 131 + if (allDids.length > 0) { 132 + try { 133 + let cursor: string | undefined = undefined; 134 + let hasMore = true; 135 + const didsSet = new Set(allDids); 136 + const followedDids = new Set<string>(); 116 137 117 - // Batch fetch profiles (25 at a time - API limit) 118 - const PROFILE_BATCH_SIZE = 25; 119 - for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) { 120 - const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE); 121 - try { 122 - const profilesResponse = await agent.app.bsky.actor.getProfiles({ 123 - actors: batch, 124 - }); 125 - 126 - profilesResponse.data.profiles.forEach((profile: any) => { 127 - profileDataMap.set(profile.did, { 128 - postCount: profile.postsCount || 0, 129 - followerCount: profile.followersCount || 0, 130 - }); 131 - }); 132 - } catch (error) { 133 - console.error("Failed to fetch profile batch:", error); 134 - // Continue even if one batch fails 135 - } 136 - } 137 - 138 - // Merge enriched data back into results 139 - results.forEach((result) => { 140 - result.actors = result.actors.map((actor: any) => { 141 - const enrichedData = profileDataMap.get(actor.did); 142 - return { 143 - ...actor, 144 - postCount: enrichedData?.postCount || 0, 145 - followerCount: enrichedData?.followerCount || 0, 146 - }; 138 + // Query user's follow graph 139 + while (hasMore && didsSet.size > 0) { 140 + const response = await agent.api.com.atproto.repo.listRecords({ 141 + repo: context.did, 142 + collection: followLexicon, 143 + limit: 100, 144 + cursor, 147 145 }); 148 - }); 149 - } 150 146 151 - // Check follow status for all matched DIDs in chosen lexicon 152 - const followLexicon = body.followLexicon || "app.bsky.graph.follow"; 153 - 154 - if (allDids.length > 0) { 155 - try { 156 - let cursor: string | undefined = undefined; 157 - let hasMore = true; 158 - const didsSet = new Set(allDids); 159 - const followedDids = new Set<string>(); 160 - const repoDid = await SessionManager.getDIDForSession(sessionId); 161 - 162 - if (repoDid === null) { 163 - throw new Error("Could not retrieve DID for session."); 164 - } 165 - 166 - // Query user's follow graph 167 - while (hasMore && didsSet.size > 0) { 168 - const response = await agent.api.com.atproto.repo.listRecords({ 169 - repo: repoDid, 170 - collection: followLexicon, 171 - limit: 100, 172 - cursor, 173 - }); 174 - 175 - // Check each record 176 - for (const record of response.data.records) { 177 - const followRecord = record.value as any; 178 - if (followRecord?.subject && didsSet.has(followRecord.subject)) { 179 - followedDids.add(followRecord.subject); 180 - } 147 + // Check each record 148 + for (const record of response.data.records) { 149 + const followRecord = record.value as any; 150 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 151 + followedDids.add(followRecord.subject); 181 152 } 182 - 183 - cursor = response.data.cursor; 184 - hasMore = !!cursor; 185 153 } 186 154 187 - // Add follow status to results 188 - results.forEach((result) => { 189 - result.actors = result.actors.map((actor: any) => ({ 190 - ...actor, 191 - followStatus: { 192 - [followLexicon]: followedDids.has(actor.did), 193 - }, 194 - })); 195 - }); 196 - } catch (error) { 197 - console.error("Failed to check follow status during search:", error); 198 - // Continue without follow status - non-critical 155 + cursor = response.data.cursor; 156 + hasMore = !!cursor; 199 157 } 200 - } 201 158 202 - return { 203 - statusCode: 200, 204 - headers: { 205 - "Content-Type": "application/json", 206 - "Access-Control-Allow-Origin": "*", 207 - }, 208 - body: JSON.stringify({ results }), 209 - }; 210 - } catch (error) { 211 - console.error("Batch search error:", error); 212 - 213 - // Handle authentication errors specifically 214 - if (error instanceof Error && error.message.includes("session")) { 215 - return { 216 - statusCode: 401, 217 - headers: { "Content-Type": "application/json" }, 218 - body: JSON.stringify({ 219 - error: "Invalid or expired session", 220 - details: error.message, 221 - }), 222 - }; 159 + // Add follow status to results 160 + results.forEach((result) => { 161 + result.actors = result.actors.map((actor: any) => ({ 162 + ...actor, 163 + followStatus: { 164 + [followLexicon]: followedDids.has(actor.did), 165 + }, 166 + })); 167 + }); 168 + } catch (error) { 169 + console.error("Failed to check follow status during search:", error); 170 + // Continue without follow status - non-critical 223 171 } 172 + } 224 173 225 - return { 226 - statusCode: 500, 227 - headers: { "Content-Type": "application/json" }, 228 - body: JSON.stringify({ 229 - error: "Failed to search actors", 230 - details: error instanceof Error ? error.message : "Unknown error", 231 - }), 232 - }; 233 - } 174 + return successResponse({ results }); 234 175 }; 176 + 177 + export const handler = withAuthErrorHandling(batchSearchHandler);
+55 -118
netlify/functions/check-follow-status.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { SessionManager } from "./session-manager"; 3 - import cookie from "cookie"; 1 + import { AuthenticatedHandler } from "./shared/types"; 2 + import { SessionService } from "./shared/services/session"; 3 + import { successResponse } from "./shared/utils"; 4 + import { withAuthErrorHandling } from "./shared/middleware"; 5 + import { ValidationError } from "./shared/constants/errors"; 4 6 5 - export const handler: Handler = async ( 6 - event: HandlerEvent, 7 - ): Promise<HandlerResponse> => { 8 - if (event.httpMethod !== "POST") { 9 - return { 10 - statusCode: 405, 11 - headers: { "Content-Type": "application/json" }, 12 - body: JSON.stringify({ error: "Method not allowed" }), 13 - }; 7 + const checkFollowStatusHandler: AuthenticatedHandler = async (context) => { 8 + // Parse request body 9 + const body = JSON.parse(context.event.body || "{}"); 10 + const dids: string[] = body.dids || []; 11 + const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; 12 + 13 + if (!Array.isArray(dids) || dids.length === 0) { 14 + throw new ValidationError("dids array is required and must not be empty"); 14 15 } 15 16 16 - try { 17 - // Parse request body 18 - const body = JSON.parse(event.body || "{}"); 19 - const dids: string[] = body.dids || []; 20 - const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; 17 + // Limit batch size 18 + if (dids.length > 100) { 19 + throw new ValidationError("Maximum 100 DIDs per batch"); 20 + } 21 21 22 - if (!Array.isArray(dids) || dids.length === 0) { 23 - return { 24 - statusCode: 400, 25 - headers: { "Content-Type": "application/json" }, 26 - body: JSON.stringify({ 27 - error: "dids array is required and must not be empty", 28 - }), 29 - }; 30 - } 22 + // Get authenticated agent using SessionService 23 + const { agent } = await SessionService.getAgentForSession(context.sessionId); 31 24 32 - // Limit batch size 33 - if (dids.length > 100) { 34 - return { 35 - statusCode: 400, 36 - headers: { "Content-Type": "application/json" }, 37 - body: JSON.stringify({ error: "Maximum 100 DIDs per batch" }), 38 - }; 39 - } 25 + // Build follow status map 26 + const followStatus: Record<string, boolean> = {}; 40 27 41 - // Get session from cookie 42 - const cookies = event.headers.cookie 43 - ? cookie.parse(event.headers.cookie) 44 - : {}; 45 - const sessionId = cookies.atlast_session; 28 + // Initialize all as not following 29 + dids.forEach((did) => { 30 + followStatus[did] = false; 31 + }); 46 32 47 - if (!sessionId) { 48 - return { 49 - statusCode: 401, 50 - headers: { "Content-Type": "application/json" }, 51 - body: JSON.stringify({ error: "No session cookie" }), 52 - }; 53 - } 33 + // Query user's follow graph for the specific lexicon 34 + try { 35 + let cursor: string | undefined = undefined; 36 + let hasMore = true; 37 + const didsSet = new Set(dids); 54 38 55 - // Get authenticated agent using SessionManager 56 - const { agent, did: userDid } = 57 - await SessionManager.getAgentForSession(sessionId); 39 + while (hasMore && didsSet.size > 0) { 40 + const response = await agent.api.com.atproto.repo.listRecords({ 41 + repo: context.did, 42 + collection: followLexicon, 43 + limit: 100, 44 + cursor, 45 + }); 58 46 59 - // Build follow status map 60 - const followStatus: Record<string, boolean> = {}; 61 - 62 - // Initialize all as not following 63 - dids.forEach((did) => { 64 - followStatus[did] = false; 65 - }); 66 - 67 - // Query user's follow graph for the specific lexicon 68 - try { 69 - let cursor: string | undefined = undefined; 70 - let hasMore = true; 71 - const didsSet = new Set(dids); 72 - 73 - while (hasMore && didsSet.size > 0) { 74 - const response = await agent.api.com.atproto.repo.listRecords({ 75 - repo: userDid, 76 - collection: followLexicon, 77 - limit: 100, 78 - cursor, 79 - }); 80 - 81 - // Check each record 82 - for (const record of response.data.records) { 83 - const followRecord = record.value as any; 84 - if (followRecord?.subject && didsSet.has(followRecord.subject)) { 85 - followStatus[followRecord.subject] = true; 86 - didsSet.delete(followRecord.subject); // Found it, no need to keep checking 87 - } 47 + // Check each record 48 + for (const record of response.data.records) { 49 + const followRecord = record.value as any; 50 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 51 + followStatus[followRecord.subject] = true; 52 + didsSet.delete(followRecord.subject); // Found it, no need to keep checking 88 53 } 54 + } 89 55 90 - cursor = response.data.cursor; 91 - hasMore = !!cursor; 56 + cursor = response.data.cursor; 57 + hasMore = !!cursor; 92 58 93 - // If we've found all DIDs, break early 94 - if (didsSet.size === 0) { 95 - break; 96 - } 59 + // If we've found all DIDs, break early 60 + if (didsSet.size === 0) { 61 + break; 97 62 } 98 - } catch (error) { 99 - console.error("Error querying follow graph:", error); 100 - // On error, return all as false (not following) - fail safe 101 63 } 102 - 103 - return { 104 - statusCode: 200, 105 - headers: { 106 - "Content-Type": "application/json", 107 - "Access-Control-Allow-Origin": "*", 108 - }, 109 - body: JSON.stringify({ followStatus }), 110 - }; 111 64 } catch (error) { 112 - console.error("Check follow status error:", error); 65 + console.error("Error querying follow graph:", error); 66 + // On error, return all as false (not following) - fail safe 67 + } 113 68 114 - // Handle authentication errors specifically 115 - if (error instanceof Error && error.message.includes("session")) { 116 - return { 117 - statusCode: 401, 118 - headers: { "Content-Type": "application/json" }, 119 - body: JSON.stringify({ 120 - error: "Invalid or expired session", 121 - details: error.message, 122 - }), 123 - }; 124 - } 69 + return successResponse({ followStatus }); 70 + }; 125 71 126 - return { 127 - statusCode: 500, 128 - headers: { "Content-Type": "application/json" }, 129 - body: JSON.stringify({ 130 - error: "Failed to check follow status", 131 - details: error instanceof Error ? error.message : "Unknown error", 132 - }), 133 - }; 134 - } 135 - }; 72 + export const handler = withAuthErrorHandling(checkFollowStatusHandler);
+7 -5
netlify/functions/client.ts netlify/functions/shared/services/oauth/client.factory.ts
··· 3 3 atprotoLoopbackClientMetadata, 4 4 } from "@atproto/oauth-client-node"; 5 5 import { JoseKey } from "@atproto/jwk-jose"; 6 - import { stateStore, sessionStore } from "./oauth-stores-db"; 7 - import { getOAuthConfig } from "./oauth-config"; 6 + import { stateStore, sessionStore } from "../session/stores"; 7 + import { getOAuthConfig } from "./config"; 8 8 9 9 function normalizePrivateKey(key: string): string { 10 10 if (!key.includes("\n") && key.includes("\\n")) { ··· 16 16 /** 17 17 * Creates and returns a configured OAuth client based on environment 18 18 * Centralizes the client creation logic used across all endpoints 19 - */ 20 - export async function createOAuthClient(): Promise<NodeOAuthClient> { 21 - const config = getOAuthConfig(); 19 + **/ 20 + export async function createOAuthClient(event?: { 21 + headers: Record<string, string | undefined>; 22 + }): Promise<NodeOAuthClient> { 23 + const config = getOAuthConfig(event); 22 24 const isDev = config.clientType === "loopback"; 23 25 24 26 if (isDev) {
-304
netlify/functions/db-helpers.ts
··· 1 - import { getDbClient } from "./db"; 2 - 3 - export async function createUpload( 4 - uploadId: string, 5 - did: string, 6 - sourcePlatform: string, 7 - totalUsers: number, 8 - matchedUsers: number, 9 - ) { 10 - const sql = getDbClient(); 11 - await sql` 12 - INSERT INTO user_uploads (upload_id, did, source_platform, total_users, matched_users, unmatched_users) 13 - VALUES (${uploadId}, ${did}, ${sourcePlatform}, ${totalUsers}, ${matchedUsers}, ${totalUsers - matchedUsers}) 14 - ON CONFLICT (upload_id) DO NOTHING 15 - `; 16 - } 17 - 18 - export async function getOrCreateSourceAccount( 19 - sourcePlatform: string, 20 - sourceUsername: string, 21 - ): Promise<number> { 22 - const sql = getDbClient(); 23 - const normalized = sourceUsername.toLowerCase().replace(/[._-]/g, ""); 24 - 25 - const result = await sql` 26 - INSERT INTO source_accounts (source_platform, source_username, normalized_username) 27 - VALUES (${sourcePlatform}, ${sourceUsername}, ${normalized}) 28 - ON CONFLICT (source_platform, normalized_username) DO UPDATE SET 29 - source_username = ${sourceUsername} 30 - RETURNING id 31 - `; 32 - 33 - return (result as any[])[0].id; 34 - } 35 - 36 - export async function linkUserToSourceAccount( 37 - uploadId: string, 38 - did: string, 39 - sourceAccountId: number, 40 - sourceDate: string, 41 - ) { 42 - const sql = getDbClient(); 43 - await sql` 44 - INSERT INTO user_source_follows (upload_id, did, source_account_id, source_date) 45 - VALUES (${uploadId}, ${did}, ${sourceAccountId}, ${sourceDate}) 46 - ON CONFLICT (upload_id, source_account_id) DO NOTHING 47 - `; 48 - } 49 - 50 - export async function storeAtprotoMatch( 51 - sourceAccountId: number, 52 - atprotoDid: string, 53 - atprotoHandle: string, 54 - atprotoDisplayName: string | undefined, 55 - atprotoAvatar: string | undefined, 56 - matchScore: number, 57 - postCount: number, 58 - followerCount: number, 59 - followStatus?: Record<string, boolean>, 60 - ): Promise<number> { 61 - const sql = getDbClient(); 62 - const result = await sql` 63 - INSERT INTO atproto_matches ( 64 - source_account_id, atproto_did, atproto_handle, 65 - atproto_display_name, atproto_avatar, match_score, 66 - post_count, follower_count, follow_status 67 - ) 68 - VALUES ( 69 - ${sourceAccountId}, ${atprotoDid}, ${atprotoHandle}, 70 - ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore}, 71 - ${postCount || 0}, ${followerCount || 0}, ${JSON.stringify(followStatus || {})} 72 - ) 73 - ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 74 - atproto_handle = ${atprotoHandle}, 75 - atproto_display_name = ${atprotoDisplayName || null}, 76 - atproto_avatar = ${atprotoAvatar || null}, 77 - match_score = ${matchScore}, 78 - post_count = ${postCount}, 79 - follower_count = ${followerCount}, 80 - follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || ${JSON.stringify(followStatus || {})}, 81 - last_verified = NOW() 82 - RETURNING id 83 - `; 84 - 85 - return (result as any[])[0].id; 86 - } 87 - 88 - export async function markSourceAccountMatched(sourceAccountId: number) { 89 - const sql = getDbClient(); 90 - await sql` 91 - UPDATE source_accounts 92 - SET match_found = true, match_found_at = NOW() 93 - WHERE id = ${sourceAccountId} 94 - `; 95 - } 96 - 97 - export async function createUserMatchStatus( 98 - did: string, 99 - atprotoMatchId: number, 100 - sourceAccountId: number, 101 - viewed: boolean = false, 102 - ) { 103 - const sql = getDbClient(); 104 - await sql` 105 - INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) 106 - VALUES (${did}, ${atprotoMatchId}, ${sourceAccountId}, ${viewed}, ${viewed ? "NOW()" : null}) 107 - ON CONFLICT (did, atproto_match_id) DO UPDATE SET 108 - viewed = ${viewed}, 109 - viewed_at = CASE WHEN ${viewed} THEN NOW() ELSE user_match_status.viewed_at END 110 - `; 111 - } 112 - 113 - // NEW: Bulk operations for Phase 2 114 - export async function bulkCreateSourceAccounts( 115 - sourcePlatform: string, 116 - usernames: string[], 117 - ): Promise<Map<string, number>> { 118 - const sql = getDbClient(); 119 - 120 - // Prepare bulk insert values 121 - const values = usernames.map((username) => ({ 122 - platform: sourcePlatform, 123 - username: username, 124 - normalized: username.toLowerCase().replace(/[._-]/g, ""), 125 - })); 126 - 127 - // Build bulk insert query with unnest 128 - const platforms = values.map((v) => v.platform); 129 - const source_usernames = values.map((v) => v.username); 130 - const normalized = values.map((v) => v.normalized); 131 - 132 - const result = await sql` 133 - INSERT INTO source_accounts (source_platform, source_username, normalized_username) 134 - SELECT * 135 - FROM UNNEST( 136 - ${platforms}::text[], 137 - ${source_usernames}::text[], 138 - ${normalized}::text[] 139 - ) AS t(source_platform, source_username, normalized_username) 140 - ON CONFLICT (source_platform, normalized_username) DO UPDATE 141 - SET source_username = EXCLUDED.source_username 142 - RETURNING id, normalized_username 143 - `; 144 - 145 - // Create map of normalized username to ID 146 - const idMap = new Map<string, number>(); 147 - for (const row of result as any[]) { 148 - idMap.set(row.normalized_username, row.id); 149 - } 150 - 151 - return idMap; 152 - } 153 - 154 - export async function bulkLinkUserToSourceAccounts( 155 - uploadId: string, 156 - did: string, 157 - links: Array<{ sourceAccountId: number; sourceDate: string }>, 158 - ) { 159 - const sql = getDbClient(); 160 - 161 - const numLinks = links.length; 162 - if (numLinks === 0) return; 163 - 164 - // Extract arrays for columns that change 165 - const sourceAccountIds = links.map((l) => l.sourceAccountId); 166 - const sourceDates = links.map((l) => l.sourceDate); 167 - 168 - // Create arrays for the static columns 169 - const uploadIds = Array(numLinks).fill(uploadId); 170 - const dids = Array(numLinks).fill(did); 171 - 172 - // Use the parallel UNNEST pattern, which is proven to work in other functions 173 - await sql` 174 - INSERT INTO user_source_follows (upload_id, did, source_account_id, source_date) 175 - SELECT * FROM UNNEST( 176 - ${uploadIds}::text[], 177 - ${dids}::text[], 178 - ${sourceAccountIds}::integer[], 179 - ${sourceDates}::text[] 180 - ) AS t(upload_id, did, source_account_id, source_date) 181 - ON CONFLICT (upload_id, source_account_id) DO NOTHING 182 - `; 183 - } 184 - // ==================================================================== 185 - 186 - export async function bulkStoreAtprotoMatches( 187 - matches: Array<{ 188 - sourceAccountId: number; 189 - atprotoDid: string; 190 - atprotoHandle: string; 191 - atprotoDisplayName?: string; 192 - atprotoAvatar?: string; 193 - atprotoDescription?: string; 194 - matchScore: number; 195 - postCount?: number; 196 - followerCount?: number; 197 - followStatus?: Record<string, boolean>; 198 - }>, 199 - ): Promise<Map<string, number>> { 200 - const sql = getDbClient(); 201 - 202 - if (matches.length === 0) return new Map(); 203 - 204 - const sourceAccountId = matches.map((m) => m.sourceAccountId); 205 - const atprotoDid = matches.map((m) => m.atprotoDid); 206 - const atprotoHandle = matches.map((m) => m.atprotoHandle); 207 - const atprotoDisplayName = matches.map((m) => m.atprotoDisplayName || null); 208 - const atprotoAvatar = matches.map((m) => m.atprotoAvatar || null); 209 - const atprotoDescription = matches.map((m) => m.atprotoDescription || null); 210 - const matchScore = matches.map((m) => m.matchScore); 211 - const postCount = matches.map((m) => m.postCount || 0); 212 - const followerCount = matches.map((m) => m.followerCount || 0); 213 - const followStatus = matches.map((m) => JSON.stringify(m.followStatus || {})); 214 - 215 - const result = await sql` 216 - INSERT INTO atproto_matches ( 217 - source_account_id, atproto_did, atproto_handle, 218 - atproto_display_name, atproto_avatar, atproto_description, 219 - match_score, post_count, follower_count, follow_status 220 - ) 221 - SELECT * FROM UNNEST( 222 - ${sourceAccountId}::integer[], 223 - ${atprotoDid}::text[], 224 - ${atprotoHandle}::text[], 225 - ${atprotoDisplayName}::text[], 226 - ${atprotoAvatar}::text[], 227 - ${atprotoDescription}::text[], 228 - ${matchScore}::integer[], 229 - ${postCount}::integer[], 230 - ${followerCount}::integer[], 231 - ${followStatus}::jsonb[] 232 - ) AS t( 233 - source_account_id, atproto_did, atproto_handle, 234 - atproto_display_name, atproto_avatar, match_score, 235 - post_count, follower_count, follow_status 236 - ) 237 - ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 238 - atproto_handle = EXCLUDED.atproto_handle, 239 - atproto_display_name = EXCLUDED.atproto_display_name, 240 - atproto_avatar = EXCLUDED.atproto_avatar, 241 - atproto_description = EXCLUDED.atproto_description, 242 - match_score = EXCLUDED.match_score, 243 - post_count = EXCLUDED.post_count, 244 - follower_count = EXCLUDED.follower_count, 245 - follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || EXCLUDED.follow_status, 246 - last_verified = NOW() 247 - RETURNING id, source_account_id, atproto_did 248 - `; 249 - 250 - // Create map of "sourceAccountId:atprotoDid" to match ID 251 - const idMap = new Map<string, number>(); 252 - for (const row of result as any[]) { 253 - idMap.set(`${row.source_account_id}:${row.atproto_did}`, row.id); 254 - } 255 - 256 - return idMap; 257 - } 258 - 259 - export async function bulkMarkSourceAccountsMatched( 260 - sourceAccountIds: number[], 261 - ) { 262 - const sql = getDbClient(); 263 - 264 - if (sourceAccountIds.length === 0) return; 265 - 266 - await sql` 267 - UPDATE source_accounts 268 - SET match_found = true, match_found_at = NOW() 269 - WHERE id = ANY(${sourceAccountIds}) 270 - `; 271 - } 272 - 273 - export async function bulkCreateUserMatchStatus( 274 - statuses: Array<{ 275 - did: string; 276 - atprotoMatchId: number; 277 - sourceAccountId: number; 278 - viewed: boolean; 279 - }>, 280 - ) { 281 - const sql = getDbClient(); 282 - 283 - if (statuses.length === 0) return; 284 - 285 - const did = statuses.map((s) => s.did); 286 - const atprotoMatchId = statuses.map((s) => s.atprotoMatchId); 287 - const sourceAccountId = statuses.map((s) => s.sourceAccountId); 288 - const viewedFlags = statuses.map((s) => s.viewed); 289 - const viewedDates = statuses.map((s) => (s.viewed ? new Date() : null)); 290 - 291 - await sql` 292 - INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) 293 - SELECT * FROM UNNEST( 294 - ${did}::text[], 295 - ${atprotoMatchId}::integer[], 296 - ${sourceAccountId}::integer[], 297 - ${viewedFlags}::boolean[], 298 - ${viewedDates}::timestamp[] 299 - ) AS t(did, atproto_match_id, source_account_id, viewed, viewed_at) 300 - ON CONFLICT (did, atproto_match_id) DO UPDATE SET 301 - viewed = EXCLUDED.viewed, 302 - viewed_at = CASE WHEN EXCLUDED.viewed THEN NOW() ELSE user_match_status.viewed_at END 303 - `; 304 - }
-212
netlify/functions/db.ts
··· 1 - import { neon, NeonQueryFunction } from "@neondatabase/serverless"; 2 - 3 - let sql: NeonQueryFunction<any, any> | undefined = undefined; 4 - let connectionInitialized = false; 5 - 6 - export function getDbClient() { 7 - if (!sql) { 8 - sql = neon(process.env.NETLIFY_DATABASE_URL!); 9 - connectionInitialized = true; 10 - } 11 - return sql; 12 - } 13 - 14 - export async function initDB() { 15 - const sql = getDbClient(); 16 - 17 - console.log("🧠 Connecting to DB:", process.env.NETLIFY_DATABASE_URL); 18 - 19 - try { 20 - const res: any = 21 - await sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`; 22 - console.log("✅ Connected:", res[0]); 23 - } catch (e) { 24 - console.error("❌ Connection failed:", e); 25 - throw e; 26 - } 27 - 28 - // OAuth Tables 29 - await sql` 30 - CREATE TABLE IF NOT EXISTS oauth_states ( 31 - key TEXT PRIMARY KEY, 32 - data JSONB NOT NULL, 33 - created_at TIMESTAMP DEFAULT NOW(), 34 - expires_at TIMESTAMP NOT NULL 35 - ) 36 - `; 37 - 38 - await sql` 39 - CREATE TABLE IF NOT EXISTS oauth_sessions ( 40 - key TEXT PRIMARY KEY, 41 - data JSONB NOT NULL, 42 - created_at TIMESTAMP DEFAULT NOW(), 43 - expires_at TIMESTAMP NOT NULL 44 - ) 45 - `; 46 - 47 - await sql` 48 - CREATE TABLE IF NOT EXISTS user_sessions ( 49 - session_id TEXT PRIMARY KEY, 50 - did TEXT NOT NULL, 51 - created_at TIMESTAMP DEFAULT NOW(), 52 - expires_at TIMESTAMP NOT NULL 53 - ) 54 - `; 55 - 56 - // User + Match Tracking 57 - await sql` 58 - CREATE TABLE IF NOT EXISTS user_uploads ( 59 - upload_id TEXT PRIMARY KEY, 60 - did TEXT NOT NULL, 61 - source_platform TEXT NOT NULL, 62 - created_at TIMESTAMP DEFAULT NOW(), 63 - last_checked TIMESTAMP, 64 - total_users INTEGER NOT NULL, 65 - matched_users INTEGER DEFAULT 0, 66 - unmatched_users INTEGER DEFAULT 0 67 - ) 68 - `; 69 - 70 - await sql` 71 - CREATE TABLE IF NOT EXISTS source_accounts ( 72 - id SERIAL PRIMARY KEY, 73 - source_platform TEXT NOT NULL, 74 - source_username TEXT NOT NULL, 75 - normalized_username TEXT NOT NULL, 76 - last_checked TIMESTAMP, 77 - match_found BOOLEAN DEFAULT FALSE, 78 - match_found_at TIMESTAMP, 79 - created_at TIMESTAMP DEFAULT NOW(), 80 - UNIQUE(source_platform, normalized_username) 81 - ) 82 - `; 83 - 84 - await sql` 85 - CREATE TABLE IF NOT EXISTS user_source_follows ( 86 - id SERIAL PRIMARY KEY, 87 - upload_id TEXT NOT NULL REFERENCES user_uploads(upload_id) ON DELETE CASCADE, 88 - did TEXT NOT NULL, 89 - source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE, 90 - source_date TEXT, 91 - created_at TIMESTAMP DEFAULT NOW(), 92 - UNIQUE(upload_id, source_account_id) 93 - ) 94 - `; 95 - 96 - await sql` 97 - CREATE TABLE IF NOT EXISTS atproto_matches ( 98 - id SERIAL PRIMARY KEY, 99 - source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE, 100 - atproto_did TEXT NOT NULL, 101 - atproto_handle TEXT NOT NULL, 102 - atproto_display_name TEXT, 103 - atproto_avatar TEXT, 104 - atproto_description TEXT, 105 - post_count INTEGER, 106 - follower_count INTEGER, 107 - match_score INTEGER NOT NULL, 108 - found_at TIMESTAMP DEFAULT NOW(), 109 - last_verified TIMESTAMP, 110 - is_active BOOLEAN DEFAULT TRUE, 111 - follow_status JSONB DEFAULT '{}', 112 - last_follow_check TIMESTAMP, 113 - UNIQUE(source_account_id, atproto_did) 114 - ) 115 - `; 116 - 117 - await sql` 118 - CREATE TABLE IF NOT EXISTS user_match_status ( 119 - id SERIAL PRIMARY KEY, 120 - did TEXT NOT NULL, 121 - atproto_match_id INTEGER NOT NULL REFERENCES atproto_matches(id) ON DELETE CASCADE, 122 - source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE, 123 - notified BOOLEAN DEFAULT FALSE, 124 - notified_at TIMESTAMP, 125 - viewed BOOLEAN DEFAULT FALSE, 126 - viewed_at TIMESTAMP, 127 - followed BOOLEAN DEFAULT FALSE, 128 - followed_at TIMESTAMP, 129 - dismissed BOOLEAN DEFAULT FALSE, 130 - dismissed_at TIMESTAMP, 131 - UNIQUE(did, atproto_match_id) 132 - ) 133 - `; 134 - 135 - await sql` 136 - CREATE TABLE IF NOT EXISTS notification_queue ( 137 - id SERIAL PRIMARY KEY, 138 - did TEXT NOT NULL, 139 - new_matches_count INTEGER NOT NULL, 140 - created_at TIMESTAMP DEFAULT NOW(), 141 - sent BOOLEAN DEFAULT FALSE, 142 - sent_at TIMESTAMP, 143 - retry_count INTEGER DEFAULT 0, 144 - last_error TEXT 145 - ) 146 - `; 147 - 148 - // Existing indexes 149 - await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 150 - await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; 151 - await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_did ON user_source_follows(did)`; 152 - await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_source ON user_source_follows(source_account_id)`; 153 - await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source ON atproto_matches(source_account_id)`; 154 - await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_did ON atproto_matches(atproto_did)`; 155 - await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_notified ON user_match_status(did, notified, viewed)`; 156 - await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`; 157 - await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 158 - 159 - // ======== Enhanced indexes for common query patterns ========= 160 - 161 - // For sorting 162 - await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`; 163 - 164 - // For session lookups (most frequent query) 165 - await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`; 166 - await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)`; 167 - 168 - // For OAuth state/session cleanup 169 - await sql`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)`; 170 - await sql`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at)`; 171 - 172 - // For upload queries by user 173 - await sql`CREATE INDEX IF NOT EXISTS idx_user_uploads_did_created ON user_uploads(did, created_at DESC)`; 174 - 175 - // For upload details pagination (composite index for ORDER BY + JOIN) 176 - await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_upload_created ON user_source_follows(upload_id, source_account_id)`; 177 - 178 - // For match status queries 179 - await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_match_id ON user_match_status(atproto_match_id)`; 180 - 181 - // Composite index for the common join pattern in get-upload-details 182 - await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source_active ON atproto_matches(source_account_id, is_active) WHERE is_active = true`; 183 - 184 - // For bulk operations - normalized username lookups 185 - await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`; 186 - 187 - // Follow status indexes 188 - await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_status ON atproto_matches USING gin(follow_status)`; 189 - await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_check ON atproto_matches(last_follow_check)`; 190 - 191 - console.log("✅ Database indexes created/verified"); 192 - } 193 - 194 - export async function cleanupExpiredSessions() { 195 - const sql = getDbClient(); 196 - 197 - // Use indexes for efficient cleanup 198 - const statesDeleted = 199 - await sql`DELETE FROM oauth_states WHERE expires_at < NOW()`; 200 - const sessionsDeleted = 201 - await sql`DELETE FROM oauth_sessions WHERE expires_at < NOW()`; 202 - const userSessionsDeleted = 203 - await sql`DELETE FROM user_sessions WHERE expires_at < NOW()`; 204 - 205 - console.log("🧹 Cleanup:", { 206 - states: (statesDeleted as any).length, 207 - sessions: (sessionsDeleted as any).length, 208 - userSessions: (userSessionsDeleted as any).length, 209 - }); 210 - } 211 - 212 - export { getDbClient as sql };
+86 -176
netlify/functions/get-upload-details.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { userSessions } from "./oauth-stores-db"; 3 - import { getDbClient } from "./db"; 4 - import cookie from "cookie"; 1 + import { AuthenticatedHandler } from "./shared/types"; 2 + import { MatchRepository } from "./shared/repositories"; 3 + import { successResponse } from "./shared/utils"; 4 + import { withAuthErrorHandling } from "./shared/middleware"; 5 + import { ValidationError, NotFoundError } from "./shared/constants/errors"; 5 6 6 7 const DEFAULT_PAGE_SIZE = 50; 7 8 const MAX_PAGE_SIZE = 100; 8 9 9 - export const handler: Handler = async ( 10 - event: HandlerEvent, 11 - ): Promise<HandlerResponse> => { 12 - try { 13 - const uploadId = event.queryStringParameters?.uploadId; 14 - const page = parseInt(event.queryStringParameters?.page || "1"); 15 - const pageSize = Math.min( 16 - parseInt( 17 - event.queryStringParameters?.pageSize || String(DEFAULT_PAGE_SIZE), 18 - ), 19 - MAX_PAGE_SIZE, 20 - ); 10 + const getUploadDetailsHandler: AuthenticatedHandler = async (context) => { 11 + const uploadId = context.event.queryStringParameters?.uploadId; 12 + const page = parseInt(context.event.queryStringParameters?.page || "1"); 13 + const pageSize = Math.min( 14 + parseInt( 15 + context.event.queryStringParameters?.pageSize || 16 + String(DEFAULT_PAGE_SIZE), 17 + ), 18 + MAX_PAGE_SIZE, 19 + ); 21 20 22 - if (!uploadId) { 23 - return { 24 - statusCode: 400, 25 - headers: { "Content-Type": "application/json" }, 26 - body: JSON.stringify({ error: "uploadId is required" }), 27 - }; 28 - } 21 + if (!uploadId) { 22 + throw new ValidationError("uploadId is required"); 23 + } 29 24 30 - if (page < 1 || pageSize < 1) { 31 - return { 32 - statusCode: 400, 33 - headers: { "Content-Type": "application/json" }, 34 - body: JSON.stringify({ error: "Invalid page or pageSize parameters" }), 35 - }; 36 - } 25 + if (page < 1 || pageSize < 1) { 26 + throw new ValidationError("Invalid page or pageSize parameters"); 27 + } 37 28 38 - // Get session from cookie 39 - const cookies = event.headers.cookie 40 - ? cookie.parse(event.headers.cookie) 41 - : {}; 42 - const sessionId = cookies.atlast_session; 29 + const matchRepo = new MatchRepository(); 43 30 44 - if (!sessionId) { 45 - return { 46 - statusCode: 401, 47 - headers: { "Content-Type": "application/json" }, 48 - body: JSON.stringify({ error: "No session cookie" }), 49 - }; 50 - } 31 + // Fetch paginated results 32 + const { results, totalUsers } = await matchRepo.getUploadDetails( 33 + uploadId, 34 + context.did, 35 + page, 36 + pageSize, 37 + ); 51 38 52 - // Get DID from session 53 - const userSession = await userSessions.get(sessionId); 54 - if (!userSession) { 55 - return { 56 - statusCode: 401, 57 - headers: { "Content-Type": "application/json" }, 58 - body: JSON.stringify({ error: "Invalid or expired session" }), 59 - }; 60 - } 61 - 62 - const sql = getDbClient(); 63 - 64 - // Verify upload belongs to user and get total count 65 - const uploadCheck = await sql` 66 - SELECT upload_id, total_users FROM user_uploads 67 - WHERE upload_id = ${uploadId} AND did = ${userSession.did} 68 - `; 69 - 70 - if ((uploadCheck as any[]).length === 0) { 71 - return { 72 - statusCode: 404, 73 - headers: { "Content-Type": "application/json" }, 74 - body: JSON.stringify({ error: "Upload not found" }), 75 - }; 76 - } 39 + if (totalUsers === 0) { 40 + throw new NotFoundError("Upload not found"); 41 + } 77 42 78 - const totalUsers = (uploadCheck as any[])[0].total_users; 79 - const totalPages = Math.ceil(totalUsers / pageSize); 80 - const offset = (page - 1) * pageSize; 81 - 82 - // Fetch paginated results with optimized query 83 - const results = await sql` 84 - SELECT 85 - sa.source_username, 86 - sa.normalized_username, 87 - usf.source_date, 88 - am.atproto_did, 89 - am.atproto_handle, 90 - am.atproto_display_name, 91 - am.atproto_avatar, 92 - am.atproto_description, 93 - am.match_score, 94 - am.post_count, 95 - am.follower_count, 96 - am.found_at, 97 - am.follow_status, 98 - am.last_follow_check, 99 - ums.followed, 100 - ums.dismissed, 101 - -- Calculate if this is a new match (found after upload creation) 102 - CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match 103 - FROM user_source_follows usf 104 - JOIN source_accounts sa ON usf.source_account_id = sa.id 105 - JOIN user_uploads uu ON usf.upload_id = uu.upload_id 106 - LEFT JOIN atproto_matches am ON sa.id = am.source_account_id AND am.is_active = true 107 - LEFT JOIN user_match_status ums ON am.id = ums.atproto_match_id AND ums.did = ${userSession.did} 108 - WHERE usf.upload_id = ${uploadId} 109 - ORDER BY 110 - -- 1. Users with matches first 111 - CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END, 112 - -- 2. New matches (found after initial upload) 113 - is_new_match DESC, 114 - -- 3. Highest post count 115 - am.post_count DESC NULLS LAST, 116 - -- 4. Highest follower count 117 - am.follower_count DESC NULLS LAST, 118 - -- 5. Username as tiebreaker 119 - sa.source_username 120 - LIMIT ${pageSize} 121 - OFFSET ${offset} 122 - `; 43 + const totalPages = Math.ceil(totalUsers / pageSize); 123 44 124 - // Group results by source username 125 - const groupedResults = new Map<string, any>(); 45 + // Group results by source username 46 + const groupedResults = new Map<string, any>(); 126 47 127 - (results as any[]).forEach((row: any) => { 128 - const username = row.source_username; 48 + results.forEach((row: any) => { 49 + const username = row.source_username; 129 50 130 - // Get or create the entry for this username 131 - let userResult = groupedResults.get(username); 51 + // Get or create the entry for this username 52 + let userResult = groupedResults.get(username); 132 53 133 - if (!userResult) { 134 - userResult = { 135 - sourceUser: { 136 - username: username, 137 - date: row.source_date || "", 138 - }, 139 - atprotoMatches: [], 140 - }; 141 - groupedResults.set(username, userResult); // Add to map, this preserves the order 142 - } 54 + if (!userResult) { 55 + userResult = { 56 + sourceUser: { 57 + username: username, 58 + date: row.source_date || "", 59 + }, 60 + atprotoMatches: [], 61 + }; 62 + groupedResults.set(username, userResult); 63 + } 143 64 144 - // Add the match (if it exists) to the array 145 - if (row.atproto_did) { 146 - userResult.atprotoMatches.push({ 147 - did: row.atproto_did, 148 - handle: row.atproto_handle, 149 - displayName: row.atproto_display_name, 150 - avatar: row.atproto_avatar, 151 - description: row.atproto_description, 152 - matchScore: row.match_score, 153 - postCount: row.post_count, 154 - followerCount: row.follower_count, 155 - foundAt: row.found_at, 156 - followed: row.followed || false, 157 - dismissed: row.dismissed || false, 158 - followStatus: row.follow_status || {}, 159 - }); 160 - } 161 - }); 65 + // Add the match (if it exists) to the array 66 + if (row.atproto_did) { 67 + userResult.atprotoMatches.push({ 68 + did: row.atproto_did, 69 + handle: row.atproto_handle, 70 + displayName: row.atproto_display_name, 71 + avatar: row.atproto_avatar, 72 + description: row.atproto_description, 73 + matchScore: row.match_score, 74 + postCount: row.post_count, 75 + followerCount: row.follower_count, 76 + foundAt: row.found_at, 77 + followed: row.followed || false, 78 + dismissed: row.dismissed || false, 79 + followStatus: row.follow_status || {}, 80 + }); 81 + } 82 + }); 162 83 163 - const searchResults = Array.from(groupedResults.values()); 84 + const searchResults = Array.from(groupedResults.values()); 164 85 165 - return { 166 - statusCode: 200, 167 - headers: { 168 - "Content-Type": "application/json", 169 - "Access-Control-Allow-Origin": "*", 170 - "Cache-Control": "private, max-age=600", // 10 minute browser cache 86 + return successResponse( 87 + { 88 + results: searchResults, 89 + pagination: { 90 + page, 91 + pageSize, 92 + totalPages, 93 + totalUsers, 94 + hasNextPage: page < totalPages, 95 + hasPrevPage: page > 1, 171 96 }, 172 - body: JSON.stringify({ 173 - results: searchResults, 174 - pagination: { 175 - page, 176 - pageSize, 177 - totalPages, 178 - totalUsers, 179 - hasNextPage: page < totalPages, 180 - hasPrevPage: page > 1, 181 - }, 182 - }), 183 - }; 184 - } catch (error) { 185 - console.error("Get upload details error:", error); 186 - return { 187 - statusCode: 500, 188 - headers: { "Content-Type": "application/json" }, 189 - body: JSON.stringify({ 190 - error: "Failed to fetch upload details", 191 - details: error instanceof Error ? error.message : "Unknown error", 192 - }), 193 - }; 194 - } 97 + }, 98 + 200, 99 + { 100 + "Cache-Control": "private, max-age=600", 101 + }, 102 + ); 195 103 }; 104 + 105 + export const handler = withAuthErrorHandling(getUploadDetailsHandler);
+20 -74
netlify/functions/get-uploads.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { userSessions } from "./oauth-stores-db"; 3 - import { getDbClient } from "./db"; 4 - import cookie from "cookie"; 1 + import { AuthenticatedHandler } from "./shared/types"; 2 + import { UploadRepository } from "./shared/repositories"; 3 + import { successResponse } from "./shared/utils"; 4 + import { withAuthErrorHandling } from "./shared/middleware"; 5 5 6 - export const handler: Handler = async ( 7 - event: HandlerEvent, 8 - ): Promise<HandlerResponse> => { 9 - try { 10 - // Get session from cookie 11 - const cookies = event.headers.cookie 12 - ? cookie.parse(event.headers.cookie) 13 - : {}; 14 - const sessionId = cookies.atlast_session; 6 + const getUploadsHandler: AuthenticatedHandler = async (context) => { 7 + const uploadRepo = new UploadRepository(); 15 8 16 - if (!sessionId) { 17 - return { 18 - statusCode: 401, 19 - headers: { "Content-Type": "application/json" }, 20 - body: JSON.stringify({ error: "No session cookie" }), 21 - }; 22 - } 23 - 24 - // Get DID from session 25 - const userSession = await userSessions.get(sessionId); 26 - if (!userSession) { 27 - return { 28 - statusCode: 401, 29 - headers: { "Content-Type": "application/json" }, 30 - body: JSON.stringify({ error: "Invalid or expired session" }), 31 - }; 32 - } 33 - 34 - const sql = getDbClient(); 35 - 36 - // Fetch all uploads for this user 37 - const uploads = await sql` 38 - SELECT 39 - upload_id, 40 - source_platform, 41 - created_at, 42 - total_users, 43 - matched_users, 44 - unmatched_users 45 - FROM user_uploads 46 - WHERE did = ${userSession.did} 47 - ORDER BY created_at DESC 48 - `; 9 + // Fetch all uploads for this user 10 + const uploads = await uploadRepo.getUserUploads(context.did); 49 11 50 - return { 51 - statusCode: 200, 52 - headers: { 53 - "Content-Type": "application/json", 54 - "Access-Control-Allow-Origin": "*", 55 - }, 56 - body: JSON.stringify({ 57 - uploads: (uploads as any[]).map((upload: any) => ({ 58 - uploadId: upload.upload_id, 59 - sourcePlatform: upload.source_platform, 60 - createdAt: upload.created_at, 61 - totalUsers: upload.total_users, 62 - matchedUsers: upload.matched_users, 63 - unmatchedUsers: upload.unmatched_users, 64 - })), 65 - }), 66 - }; 67 - } catch (error) { 68 - console.error("Get uploads error:", error); 69 - return { 70 - statusCode: 500, 71 - headers: { "Content-Type": "application/json" }, 72 - body: JSON.stringify({ 73 - error: "Failed to fetch uploads", 74 - details: error instanceof Error ? error.message : "Unknown error", 75 - }), 76 - }; 77 - } 12 + return successResponse({ 13 + uploads: uploads.map((upload) => ({ 14 + uploadId: upload.upload_id, 15 + sourcePlatform: upload.source_platform, 16 + createdAt: upload.created_at, 17 + totalUsers: upload.total_users, 18 + matchedUsers: upload.matched_users, 19 + unmatchedUsers: upload.unmatched_users, 20 + })), 21 + }); 78 22 }; 23 + 24 + export const handler = withAuthErrorHandling(getUploadsHandler);
+10 -22
netlify/functions/init-db.ts
··· 1 - import { Handler } from "@netlify/functions"; 2 - import { initDB } from "./db"; 1 + import { SimpleHandler } from "./shared/types/api.types"; 2 + import { DatabaseService } from "./shared/services/database"; 3 + import { withErrorHandling } from "./shared/middleware"; 4 + import { successResponse } from "./shared/utils"; 3 5 4 - export const handler: Handler = async () => { 5 - try { 6 - await initDB(); 7 - return { 8 - statusCode: 200, 9 - headers: { "Content-Type": "application/json" }, 10 - body: JSON.stringify({ message: "Database initialized successfully" }), 11 - }; 12 - } catch (error) { 13 - console.error("Database initialization error:", error); 14 - return { 15 - statusCode: 500, 16 - headers: { "Content-Type": "application/json" }, 17 - body: JSON.stringify({ 18 - error: "Failed to initialize database", 19 - details: error instanceof Error ? error.message : "Unknown error", 20 - stack: error instanceof Error ? error.stack : undefined, 21 - }), 22 - }; 23 - } 6 + const initDbHandler: SimpleHandler = async () => { 7 + const dbService = new DatabaseService(); 8 + await dbService.initDatabase(); 9 + return successResponse({ message: "Database initialized successfully" }); 24 10 }; 11 + 12 + export const handler = withErrorHandling(initDbHandler);
+32 -51
netlify/functions/logout.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { SessionManager } from "./session-manager"; 3 - import { getOAuthConfig } from "./oauth-config"; 4 - import cookie from "cookie"; 1 + import { SimpleHandler } from "./shared/types/api.types"; 2 + import { SessionService } from "./shared/services/session"; 3 + import { getOAuthConfig } from "./shared/services/oauth"; 4 + import { extractSessionId } from "./shared/middleware"; 5 + import { withErrorHandling } from "./shared/middleware"; 5 6 6 - export const handler: Handler = async ( 7 - event: HandlerEvent, 8 - ): Promise<HandlerResponse> => { 7 + const logoutHandler: SimpleHandler = async (event) => { 9 8 // Only allow POST for logout 10 9 if (event.httpMethod !== "POST") { 11 - return { 12 - statusCode: 405, 13 - headers: { "Content-Type": "application/json" }, 14 - body: JSON.stringify({ error: "Method not allowed" }), 15 - }; 10 + throw new Error("Method not allowed"); 16 11 } 17 12 18 - try { 19 - console.log("[logout] Starting logout process..."); 20 - console.log("[logout] Cookies received:", event.headers.cookie); 13 + console.log("[logout] Starting logout process..."); 14 + console.log("[logout] Cookies received:", event.headers.cookie); 21 15 22 - // Get session from cookie 23 - const cookies = event.headers.cookie 24 - ? cookie.parse(event.headers.cookie) 25 - : {}; 26 - const sessionId = cookies.atlast_session; 27 - console.log("[logout] Session ID from cookie:", sessionId); 16 + const sessionId = extractSessionId(event); 17 + console.log("[logout] Session ID from cookie:", sessionId); 28 18 29 - if (sessionId) { 30 - // Use SessionManager to properly clean up both user and OAuth sessions 31 - await SessionManager.deleteSession(sessionId); 32 - console.log("[logout] Successfully deleted session:", sessionId); 33 - } 19 + if (sessionId) { 20 + // Use SessionService to properly clean up both user and OAuth sessions 21 + await SessionService.deleteSession(sessionId); 22 + console.log("[logout] Successfully deleted session:", sessionId); 23 + } 34 24 35 - // Clear the session cookie with matching flags from when it was set 36 - const config = getOAuthConfig(); 37 - const isDev = config.clientType === "loopback"; 25 + // Clear the session cookie with matching flags from when it was set 26 + const config = getOAuthConfig(); 27 + const isDev = config.clientType === "loopback"; 38 28 39 - const cookieFlags = isDev 40 - ? "HttpOnly; SameSite=Lax; Max-Age=0; Path=/" 41 - : "HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure"; 29 + const cookieFlags = isDev 30 + ? `HttpOnly; SameSite=Lax; Max-Age=0; Path=/` 31 + : `HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure`; 42 32 43 - return { 44 - statusCode: 200, 45 - headers: { 46 - "Content-Type": "application/json", 47 - "Set-Cookie": `atlast_session=; ${cookieFlags}`, 48 - }, 49 - body: JSON.stringify({ success: true }), 50 - }; 51 - } catch (error) { 52 - console.error("Logout error:", error); 53 - return { 54 - statusCode: 500, 55 - headers: { "Content-Type": "application/json" }, 56 - body: JSON.stringify({ 57 - error: "Failed to logout", 58 - details: error instanceof Error ? error.message : "Unknown error", 59 - }), 60 - }; 61 - } 33 + return { 34 + statusCode: 200, 35 + headers: { 36 + "Content-Type": "application/json", 37 + "Set-Cookie": `atlast_session=; ${cookieFlags}`, 38 + }, 39 + body: JSON.stringify({ success: true }), 40 + }; 62 41 }; 42 + 43 + export const handler = withErrorHandling(logoutHandler);
+42 -66
netlify/functions/oauth-callback.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { createOAuthClient } from "./client"; 3 - import { userSessions } from "./oauth-stores-db"; 4 - import { getOAuthConfig } from "./oauth-config"; 1 + import { SimpleHandler } from "./shared/types/api.types"; 2 + import { createOAuthClient, getOAuthConfig } from "./shared/services/oauth"; 3 + import { userSessions } from "./shared/services/session"; 4 + import { redirectResponse } from "./shared/utils"; 5 + import { withErrorHandling } from "./shared/middleware"; 6 + import { CONFIG } from "./shared/constants"; 5 7 import * as crypto from "crypto"; 6 8 7 - export const handler: Handler = async ( 8 - event: HandlerEvent, 9 - ): Promise<HandlerResponse> => { 10 - const config = getOAuthConfig(); 9 + const oauthCallbackHandler: SimpleHandler = async (event) => { 10 + const config = getOAuthConfig(event); 11 11 const isDev = config.clientType === "loopback"; 12 12 13 13 let currentUrl = isDev 14 14 ? "http://127.0.0.1:8888" 15 - : process.env.DEPLOY_URL 16 - ? `https://${new URL(process.env.DEPLOY_URL).host}` 17 - : process.env.URL || 18 - process.env.DEPLOY_PRIME_URL || 19 - "https://atlast.byarielm.fyi"; 15 + : config.redirectUri.replace("/.netlify/functions/oauth-callback", ""); 20 16 21 - try { 22 - const params = new URLSearchParams(event.rawUrl.split("?")[1] || ""); 23 - const code = params.get("code"); 24 - const state = params.get("state"); 17 + const params = new URLSearchParams(event.rawUrl.split("?")[1] || ""); 18 + const code = params.get("code"); 19 + const state = params.get("state"); 25 20 26 - console.log( 27 - "[oauth-callback] Processing callback - Mode:", 28 - isDev ? "loopback" : "production", 29 - ); 30 - console.log("[oauth-callback] URL:", currentUrl); 21 + console.log( 22 + "[oauth-callback] Processing callback - Mode:", 23 + isDev ? "loopback" : "production", 24 + ); 25 + console.log("[oauth-callback] URL:", currentUrl); 31 26 32 - if (!code || !state) { 33 - return { 34 - statusCode: 302, 35 - headers: { 36 - Location: `${currentUrl}/?error=Missing OAuth parameters`, 37 - }, 38 - body: "", 39 - }; 40 - } 27 + if (!code || !state) { 28 + return redirectResponse(`${currentUrl}/?error=Missing OAuth parameters`); 29 + } 41 30 42 - // Create OAuth client using shared helper 43 - const client = await createOAuthClient(); 31 + // Create OAuth client using shared helper 32 + const client = await createOAuthClient(); 44 33 45 - // Process the OAuth callback 46 - const result = await client.callback(params); 34 + // Process the OAuth callback 35 + const result = await client.callback(params); 47 36 48 - console.log( 49 - "[oauth-callback] Successfully authenticated DID:", 50 - result.session.did, 51 - ); 37 + console.log( 38 + "[oauth-callback] Successfully authenticated DID:", 39 + result.session.did, 40 + ); 52 41 53 - // Store session 54 - const sessionId = crypto.randomUUID(); 55 - const did = result.session.did; 56 - await userSessions.set(sessionId, { did }); 42 + // Store session 43 + const sessionId = crypto.randomUUID(); 44 + const did = result.session.did; 45 + await userSessions.set(sessionId, { did }); 57 46 58 - console.log("[oauth-callback] Created user session:", sessionId); 47 + console.log("[oauth-callback] Created user session:", sessionId); 59 48 60 - // Cookie flags - no Secure flag for loopback 61 - const cookieFlags = isDev 62 - ? "HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/" 63 - : "HttpOnly; SameSite=Lax; Max-Age=1209600; Path=/; Secure"; 49 + // Cookie flags - no Secure flag for loopback 50 + const cookieFlags = isDev 51 + ? `HttpOnly; SameSite=Lax; Max-Age=${CONFIG.COOKIE_MAX_AGE}; Path=/` 52 + : `HttpOnly; SameSite=Lax; Max-Age=${CONFIG.COOKIE_MAX_AGE}; Path=/; Secure`; 64 53 65 - return { 66 - statusCode: 302, 67 - headers: { 68 - Location: `${currentUrl}/?session=${sessionId}`, 69 - "Set-Cookie": `atlast_session=${sessionId}; ${cookieFlags}`, 70 - }, 71 - body: "", 72 - }; 73 - } catch (error) { 74 - console.error("OAuth callback error:", error); 75 - return { 76 - statusCode: 302, 77 - headers: { 78 - Location: `${currentUrl}/?error=OAuth failed: ${error instanceof Error ? error.message : "Unknown error"}`, 79 - }, 80 - body: "", 81 - }; 82 - } 54 + return redirectResponse(`${currentUrl}/?session=${sessionId}`, [ 55 + `atlast_session=${sessionId}; ${cookieFlags}`, 56 + ]); 83 57 }; 58 + 59 + export const handler = withErrorHandling(oauthCallbackHandler);
-61
netlify/functions/oauth-config.ts
··· 1 - export function getOAuthConfig() { 2 - // Check if we have a public URL (production or --live mode) 3 - const baseUrl = 4 - process.env.URL || process.env.DEPLOY_URL || process.env.DEPLOY_PRIME_URL; 5 - 6 - // Development: loopback client for local dev 7 - // Check if we're running on localhost (true local dev) 8 - const isLocalhost = 9 - !baseUrl || 10 - baseUrl.includes("localhost") || 11 - baseUrl.includes("127.0.0.1") || 12 - baseUrl.startsWith("http://localhost") || 13 - baseUrl.startsWith("http://127.0.0.1"); 14 - 15 - // Use loopback for localhost, production for everything else 16 - const isDev = isLocalhost; 17 - 18 - if (isDev) { 19 - const port = process.env.PORT || "8888"; 20 - 21 - // Special loopback client_id format with query params 22 - const clientId = `http://localhost?${new URLSearchParams([ 23 - [ 24 - "redirect_uri", 25 - `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 26 - ], 27 - ["scope", "atproto transition:generic"], 28 - ])}`; 29 - 30 - console.log("Using loopback OAuth for local development"); 31 - console.log("Access your app at: http://127.0.0.1:" + port); 32 - 33 - return { 34 - clientId: clientId, 35 - redirectUri: `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 36 - jwksUri: undefined, 37 - clientType: "loopback" as const, 38 - }; 39 - } 40 - 41 - // Production: discoverable client logic 42 - if (!baseUrl) { 43 - throw new Error("No public URL available"); 44 - } 45 - 46 - console.log("Using confidential OAuth client for production"); 47 - console.log("OAuth Config URLs:", { 48 - DEPLOY_PRIME_URL: process.env.DEPLOY_PRIME_URL, 49 - URL: process.env.URL, 50 - CONTEXT: process.env.CONTEXT, 51 - using: baseUrl, 52 - }); 53 - 54 - return { 55 - clientId: `${baseUrl}/oauth-client-metadata.json`, // discoverable client URL 56 - redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 57 - jwksUri: `${baseUrl}/.netlify/functions/jwks`, 58 - clientType: "discoverable" as const, 59 - usePrivateKey: true, 60 - }; 61 - }
+25 -67
netlify/functions/oauth-start.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { createOAuthClient } from "./client"; 1 + import { SimpleHandler } from "./shared/types/api.types"; 2 + import { createOAuthClient } from "./shared/services/oauth"; 3 + import { successResponse } from "./shared/utils"; 4 + import { withErrorHandling } from "./shared/middleware"; 5 + import { ValidationError } from "./shared/constants/errors"; 3 6 4 7 interface OAuthStartRequestBody { 5 8 login_hint?: string; 6 9 origin?: string; 7 10 } 8 11 9 - export const handler: Handler = async ( 10 - event: HandlerEvent, 11 - ): Promise<HandlerResponse> => { 12 - try { 13 - let loginHint: string | undefined = undefined; 14 - 15 - if (event.body) { 16 - const parsed: OAuthStartRequestBody = JSON.parse(event.body); 17 - loginHint = parsed.login_hint; 18 - } 19 - 20 - if (!loginHint) { 21 - return { 22 - statusCode: 400, 23 - headers: { "Content-Type": "application/json" }, 24 - body: JSON.stringify({ 25 - error: "login_hint (handle or DID) is required", 26 - }), 27 - }; 28 - } 12 + const oauthStartHandler: SimpleHandler = async (event) => { 13 + let loginHint: string | undefined = undefined; 29 14 30 - console.log("[oauth-start] Starting OAuth flow for:", loginHint); 15 + if (event.body) { 16 + const parsed: OAuthStartRequestBody = JSON.parse(event.body); 17 + loginHint = parsed.login_hint; 18 + } 31 19 32 - // Create OAuth client using shared helper 33 - const client = await createOAuthClient(); 20 + if (!loginHint) { 21 + throw new ValidationError("login_hint (handle or DID) is required"); 22 + } 34 23 35 - // Start the authorization flow 36 - const authUrl = await client.authorize(loginHint, { 37 - scope: "atproto transition:generic", 38 - }); 39 - 40 - console.log("[oauth-start] Generated auth URL for:", loginHint); 24 + console.log("[oauth-start] Starting OAuth flow for:", loginHint); 41 25 42 - return { 43 - statusCode: 200, 44 - headers: { "Content-Type": "application/json" }, 45 - body: JSON.stringify({ url: authUrl.toString() }), 46 - }; 47 - } catch (error) { 48 - console.error("OAuth start error:", error); 26 + // Create OAuth client using shared helper 27 + const client = await createOAuthClient(event); 49 28 50 - // Provide user-friendly error messages 51 - let userMessage = "Failed to start authentication"; 29 + // Start the authorization flow 30 + const authUrl = await client.authorize(loginHint, { 31 + scope: "atproto transition:generic", 32 + }); 52 33 53 - if (error instanceof Error) { 54 - if ( 55 - error.message.includes("resolve") || 56 - error.message.includes("not found") 57 - ) { 58 - userMessage = 59 - "Account not found. Please check your handle and try again."; 60 - } else if ( 61 - error.message.includes("network") || 62 - error.message.includes("timeout") 63 - ) { 64 - userMessage = 65 - "Network error. Please check your connection and try again."; 66 - } else if (error.message.includes("Invalid identifier")) { 67 - userMessage = 68 - "Invalid handle format. Please use the format: username.bsky.social"; 69 - } 70 - } 34 + console.log("[oauth-start] Generated auth URL for:", loginHint); 71 35 72 - return { 73 - statusCode: 500, 74 - headers: { "Content-Type": "application/json" }, 75 - body: JSON.stringify({ 76 - error: userMessage, 77 - details: error instanceof Error ? error.message : "Unknown error", 78 - }), 79 - }; 80 - } 36 + return successResponse({ url: authUrl.toString() }); 81 37 }; 38 + 39 + export const handler = withErrorHandling(oauthStartHandler);
-94
netlify/functions/oauth-stores-db.ts
··· 1 - import { getDbClient } from "./db"; 2 - 3 - interface StateData { 4 - dpopKey: any; 5 - verifier: string; 6 - appState?: string; 7 - } 8 - 9 - interface SessionData { 10 - dpopKey: any; 11 - tokenSet: any; 12 - } 13 - 14 - // Reuse the same DB client across all store instances 15 - const sql = getDbClient(); 16 - 17 - export class PostgresStateStore { 18 - async get(key: string): Promise<StateData | undefined> { 19 - const result = await sql` 20 - SELECT data FROM oauth_states 21 - WHERE key = ${key} AND expires_at > NOW() 22 - `; 23 - return (result as Record<string, any>[])[0]?.data as StateData | undefined; 24 - } 25 - 26 - async set(key: string, value: StateData): Promise<void> { 27 - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes 28 - await sql` 29 - INSERT INTO oauth_states (key, data, expires_at) 30 - VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt.toISOString()}) 31 - ON CONFLICT (key) DO UPDATE SET data = ${JSON.stringify(value)}, expires_at = ${expiresAt.toISOString()} 32 - `; 33 - } 34 - 35 - async del(key: string): Promise<void> { 36 - await sql`DELETE FROM oauth_states WHERE key = ${key}`; 37 - } 38 - } 39 - 40 - export class PostgresSessionStore { 41 - async get(key: string): Promise<SessionData | undefined> { 42 - const result = await sql` 43 - SELECT data FROM oauth_sessions 44 - WHERE key = ${key} AND expires_at > NOW() 45 - `; 46 - return (result as Record<string, any>[])[0]?.data as 47 - | SessionData 48 - | undefined; 49 - } 50 - 51 - async set(key: string, value: SessionData): Promise<void> { 52 - // Session includes tokens, DPoP keys, etc. 53 - const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); // 14 days 54 - await sql` 55 - INSERT INTO oauth_sessions (key, data, expires_at) 56 - VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt}) 57 - ON CONFLICT (key) DO UPDATE SET data = ${JSON.stringify(value)}, expires_at = ${expiresAt} 58 - `; 59 - } 60 - 61 - async del(key: string): Promise<void> { 62 - await sql`DELETE FROM oauth_sessions WHERE key = ${key}`; 63 - } 64 - } 65 - 66 - export class PostgresUserSessionStore { 67 - async get(sessionId: string): Promise<{ did: string } | undefined> { 68 - const result = await sql` 69 - SELECT did FROM user_sessions 70 - WHERE session_id = ${sessionId} AND expires_at > NOW() 71 - `; 72 - const row = (result as Record<string, any>[])[0]; 73 - return row ? { did: row.did } : undefined; 74 - } 75 - 76 - async set(sessionId: string, data: { did: string }): Promise<void> { 77 - const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); // 14 days 78 - await sql` 79 - INSERT INTO user_sessions (session_id, did, expires_at) 80 - VALUES (${sessionId}, ${data.did}, ${expiresAt}) 81 - ON CONFLICT (session_id) DO UPDATE SET 82 - did = ${data.did}, 83 - expires_at = ${expiresAt} 84 - `; 85 - } 86 - 87 - async del(sessionId: string): Promise<void> { 88 - await sql`DELETE FROM user_sessions WHERE session_id = ${sessionId}`; 89 - } 90 - } 91 - 92 - export const stateStore = new PostgresStateStore(); 93 - export const sessionStore = new PostgresSessionStore(); 94 - export const userSessions = new PostgresUserSessionStore();
+155 -225
netlify/functions/save-results.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { userSessions } from "./oauth-stores-db"; 3 - import cookie from "cookie"; 1 + import { AuthenticatedHandler } from "./shared/types"; 4 2 import { 5 - createUpload, 6 - bulkCreateSourceAccounts, 7 - bulkLinkUserToSourceAccounts, 8 - bulkStoreAtprotoMatches, 9 - bulkMarkSourceAccountsMatched, 10 - bulkCreateUserMatchStatus, 11 - } from "./db-helpers"; 12 - import { getDbClient } from "./db"; 3 + UploadRepository, 4 + SourceAccountRepository, 5 + MatchRepository, 6 + } from "./shared/repositories"; 7 + import { successResponse } from "./shared/utils"; 8 + import { withAuthErrorHandling } from "./shared/middleware"; 9 + import { ValidationError } from "./shared/constants/errors"; 13 10 14 11 interface SearchResult { 15 12 sourceUser: { ··· 38 35 saveData?: boolean; 39 36 } 40 37 41 - export const handler: Handler = async ( 42 - event: HandlerEvent, 43 - ): Promise<HandlerResponse> => { 44 - if (event.httpMethod !== "POST") { 45 - return { 46 - statusCode: 405, 47 - headers: { "Content-Type": "application/json" }, 48 - body: JSON.stringify({ error: "Method not allowed" }), 49 - }; 50 - } 51 - 52 - try { 53 - // Get session from cookie 54 - const cookies = event.headers.cookie 55 - ? cookie.parse(event.headers.cookie) 56 - : {}; 57 - const sessionId = cookies.atlast_session; 58 - 59 - if (!sessionId) { 60 - return { 61 - statusCode: 401, 62 - headers: { "Content-Type": "application/json" }, 63 - body: JSON.stringify({ error: "No session cookie" }), 64 - }; 65 - } 66 - 67 - // Get DID from session 68 - const userSession = await userSessions.get(sessionId); 69 - if (!userSession) { 70 - return { 71 - statusCode: 401, 72 - headers: { "Content-Type": "application/json" }, 73 - body: JSON.stringify({ error: "Invalid or expired session" }), 74 - }; 75 - } 76 - 77 - // Parse request body 78 - const body: SaveResultsRequest = JSON.parse(event.body || "{}"); 79 - const { uploadId, sourcePlatform, results, saveData } = body; 80 - 81 - if (!uploadId || !sourcePlatform || !Array.isArray(results)) { 82 - return { 83 - statusCode: 400, 84 - headers: { "Content-Type": "application/json" }, 85 - body: JSON.stringify({ 86 - error: "uploadId, sourcePlatform, and results are required", 87 - }), 88 - }; 89 - } 90 - 91 - // Server-side validation for saveData flag, controlled by frontend 92 - if (saveData === false) { 93 - console.log( 94 - `User ${userSession.did} has data storage disabled - skipping save`, 95 - ); 96 - return { 97 - statusCode: 200, 98 - headers: { "Content-Type": "application/json" }, 99 - body: JSON.stringify({ 100 - success: true, 101 - message: "Data storage disabled - results not saved", 102 - uploadId, 103 - totalUsers: results.length, 104 - matchedUsers: results.filter((r) => r.atprotoMatches.length > 0) 105 - .length, 106 - unmatchedUsers: results.filter((r) => r.atprotoMatches.length === 0) 107 - .length, 108 - }), 109 - }; 110 - } 111 - 112 - const sql = getDbClient(); 113 - let matchedCount = 0; 114 - 115 - // Check for recent uploads from this user 116 - const recentUpload = await sql` 117 - SELECT upload_id FROM user_uploads 118 - WHERE did = ${userSession.did} 119 - AND created_at > NOW() - INTERVAL '5 seconds' 120 - ORDER BY created_at DESC 121 - LIMIT 1 122 - `; 123 - 124 - if ((recentUpload as any[]).length > 0) { 125 - console.log( 126 - `User ${userSession.did} already saved within 5 seconds, skipping duplicate`, 127 - ); 128 - return { 129 - statusCode: 200, 130 - headers: { "Content-Type": "application/json" }, 131 - body: JSON.stringify({ success: true, message: "Recently saved" }), 132 - }; 133 - } 38 + const saveResultsHandler: AuthenticatedHandler = async (context) => { 39 + // Parse request body 40 + const body: SaveResultsRequest = JSON.parse(context.event.body || "{}"); 41 + const { uploadId, sourcePlatform, results, saveData } = body; 134 42 135 - // Create upload record FIRST 136 - await createUpload( 137 - uploadId, 138 - userSession.did, 139 - sourcePlatform, 140 - results.length, 141 - 0, 43 + if (!uploadId || !sourcePlatform || !Array.isArray(results)) { 44 + throw new ValidationError( 45 + "uploadId, sourcePlatform, and results are required", 142 46 ); 47 + } 143 48 144 - // BULK OPERATION 1: Create all source accounts at once 145 - const allUsernames = results.map((r) => r.sourceUser.username); 146 - const sourceAccountIdMap = await bulkCreateSourceAccounts( 147 - sourcePlatform, 148 - allUsernames, 49 + // Server-side validation for saveData flag, controlled by frontend 50 + if (saveData === false) { 51 + console.log( 52 + `User ${context.did} has data storage disabled - skipping save`, 149 53 ); 54 + return successResponse({ 55 + success: true, 56 + message: "Data storage disabled - results not saved", 57 + uploadId, 58 + totalUsers: results.length, 59 + matchedUsers: results.filter((r) => r.atprotoMatches.length > 0).length, 60 + unmatchedUsers: results.filter((r) => r.atprotoMatches.length === 0) 61 + .length, 62 + }); 63 + } 150 64 151 - // BULK OPERATION 2: Link all users to source accounts 152 - const links = results 153 - .map((result) => { 154 - const normalized = result.sourceUser.username 155 - .toLowerCase() 156 - .replace(/[._-]/g, ""); 157 - const sourceAccountId = sourceAccountIdMap.get(normalized); 158 - return { 159 - sourceAccountId: sourceAccountId!, 160 - sourceDate: result.sourceUser.date, 161 - }; 162 - }) 163 - .filter((link) => link.sourceAccountId !== undefined); 65 + const uploadRepo = new UploadRepository(); 66 + const sourceAccountRepo = new SourceAccountRepository(); 67 + const matchRepo = new MatchRepository(); 68 + let matchedCount = 0; 164 69 165 - await bulkLinkUserToSourceAccounts(uploadId, userSession.did, links); 70 + // Check for recent uploads from this user 71 + const hasRecent = await uploadRepo.hasRecentUpload(context.did); 72 + if (hasRecent) { 73 + console.log( 74 + `User ${context.did} already saved within 5 seconds, skipping duplicate`, 75 + ); 76 + return successResponse({ 77 + success: true, 78 + message: "Recently saved", 79 + }); 80 + } 166 81 167 - // BULK OPERATION 3: Store all atproto matches at once 168 - const allMatches: Array<{ 169 - sourceAccountId: number; 170 - atprotoDid: string; 171 - atprotoHandle: string; 172 - atprotoDisplayName?: string; 173 - atprotoAvatar?: string; 174 - atprotoDescription?: string; 175 - matchScore: number; 176 - postCount: number; 177 - followerCount: number; 178 - }> = []; 82 + // Create upload record FIRST 83 + await uploadRepo.createUpload( 84 + uploadId, 85 + context.did, 86 + sourcePlatform, 87 + results.length, 88 + 0, 89 + ); 179 90 180 - const matchedSourceAccountIds: number[] = []; 91 + // BULK OPERATION 1: Create all source accounts at once 92 + const allUsernames = results.map((r) => r.sourceUser.username); 93 + const sourceAccountIdMap = await sourceAccountRepo.bulkCreate( 94 + sourcePlatform, 95 + allUsernames, 96 + ); 181 97 182 - for (const result of results) { 98 + // BULK OPERATION 2: Link all users to source accounts 99 + const links = results 100 + .map((result) => { 183 101 const normalized = result.sourceUser.username 184 102 .toLowerCase() 185 103 .replace(/[._-]/g, ""); 186 104 const sourceAccountId = sourceAccountIdMap.get(normalized); 105 + return { 106 + sourceAccountId: sourceAccountId!, 107 + sourceDate: result.sourceUser.date, 108 + }; 109 + }) 110 + .filter((link) => link.sourceAccountId !== undefined); 187 111 188 - if ( 189 - sourceAccountId && 190 - result.atprotoMatches && 191 - result.atprotoMatches.length > 0 192 - ) { 193 - matchedCount++; 194 - matchedSourceAccountIds.push(sourceAccountId); 112 + await sourceAccountRepo.linkUserToAccounts(uploadId, context.did, links); 195 113 196 - for (const match of result.atprotoMatches) { 197 - allMatches.push({ 198 - sourceAccountId, 199 - atprotoDid: match.did, 200 - atprotoHandle: match.handle, 201 - atprotoDisplayName: match.displayName, 202 - atprotoAvatar: match.avatar, 203 - atprotoDescription: (match as any).description, 204 - matchScore: match.matchScore, 205 - postCount: match.postCount || 0, 206 - followerCount: match.followerCount || 0, 207 - }); 208 - } 209 - } 210 - } 114 + // BULK OPERATION 3: Store all atproto matches at once 115 + const allMatches: Array<{ 116 + sourceAccountId: number; 117 + atprotoDid: string; 118 + atprotoHandle: string; 119 + atprotoDisplayName?: string; 120 + atprotoAvatar?: string; 121 + atprotoDescription?: string; 122 + matchScore: number; 123 + postCount: number; 124 + followerCount: number; 125 + }> = []; 211 126 212 - // Store all matches in one operation 213 - let matchIdMap = new Map<string, number>(); 214 - if (allMatches.length > 0) { 215 - matchIdMap = await bulkStoreAtprotoMatches(allMatches); 216 - } 127 + const matchedSourceAccountIds: number[] = []; 217 128 218 - // BULK OPERATION 4: Mark all matched source accounts 219 - if (matchedSourceAccountIds.length > 0) { 220 - await bulkMarkSourceAccountsMatched(matchedSourceAccountIds); 221 - } 129 + for (const result of results) { 130 + const normalized = result.sourceUser.username 131 + .toLowerCase() 132 + .replace(/[._-]/g, ""); 133 + const sourceAccountId = sourceAccountIdMap.get(normalized); 222 134 223 - // BULK OPERATION 5: Create all user match statuses 224 - const statuses: Array<{ 225 - did: string; 226 - atprotoMatchId: number; 227 - sourceAccountId: number; 228 - viewed: boolean; 229 - }> = []; 135 + if ( 136 + sourceAccountId && 137 + result.atprotoMatches && 138 + result.atprotoMatches.length > 0 139 + ) { 140 + matchedCount++; 141 + matchedSourceAccountIds.push(sourceAccountId); 230 142 231 - for (const match of allMatches) { 232 - const key = `${match.sourceAccountId}:${match.atprotoDid}`; 233 - const matchId = matchIdMap.get(key); 234 - if (matchId) { 235 - statuses.push({ 236 - did: userSession.did, 237 - atprotoMatchId: matchId, 238 - sourceAccountId: match.sourceAccountId, 239 - viewed: true, 143 + for (const match of result.atprotoMatches) { 144 + allMatches.push({ 145 + sourceAccountId, 146 + atprotoDid: match.did, 147 + atprotoHandle: match.handle, 148 + atprotoDisplayName: match.displayName, 149 + atprotoAvatar: match.avatar, 150 + atprotoDescription: (match as any).description, 151 + matchScore: match.matchScore, 152 + postCount: match.postCount || 0, 153 + followerCount: match.followerCount || 0, 240 154 }); 241 155 } 242 156 } 157 + } 243 158 244 - if (statuses.length > 0) { 245 - await bulkCreateUserMatchStatus(statuses); 159 + // Store all matches in one operation 160 + let matchIdMap = new Map<string, number>(); 161 + if (allMatches.length > 0) { 162 + matchIdMap = await matchRepo.bulkStoreMatches(allMatches); 163 + } 164 + 165 + // BULK OPERATION 4: Mark all matched source accounts 166 + if (matchedSourceAccountIds.length > 0) { 167 + await sourceAccountRepo.markAsMatched(matchedSourceAccountIds); 168 + } 169 + 170 + // BULK OPERATION 5: Create all user match statuses 171 + const statuses: Array<{ 172 + did: string; 173 + atprotoMatchId: number; 174 + sourceAccountId: number; 175 + viewed: boolean; 176 + }> = []; 177 + 178 + for (const match of allMatches) { 179 + const key = `${match.sourceAccountId}:${match.atprotoDid}`; 180 + const matchId = matchIdMap.get(key); 181 + if (matchId) { 182 + statuses.push({ 183 + did: context.did, 184 + atprotoMatchId: matchId, 185 + sourceAccountId: match.sourceAccountId, 186 + viewed: true, 187 + }); 246 188 } 189 + } 190 + 191 + if (statuses.length > 0) { 192 + await matchRepo.upsertUserMatchStatus(statuses); 193 + } 247 194 248 - // Update upload record with final counts 249 - await sql` 250 - UPDATE user_uploads 251 - SET matched_users = ${matchedCount}, 252 - unmatched_users = ${results.length - matchedCount} 253 - WHERE upload_id = ${uploadId} 254 - `; 195 + // Update upload record with final counts 196 + await uploadRepo.updateMatchCounts( 197 + uploadId, 198 + matchedCount, 199 + results.length - matchedCount, 200 + ); 255 201 256 - return { 257 - statusCode: 200, 258 - headers: { 259 - "Content-Type": "application/json", 260 - "Access-Control-Allow-Origin": "*", 261 - }, 262 - body: JSON.stringify({ 263 - success: true, 264 - uploadId, 265 - totalUsers: results.length, 266 - matchedUsers: matchedCount, 267 - unmatchedUsers: results.length - matchedCount, 268 - }), 269 - }; 270 - } catch (error) { 271 - console.error("Save results error:", error); 272 - return { 273 - statusCode: 500, 274 - headers: { "Content-Type": "application/json" }, 275 - body: JSON.stringify({ 276 - error: "Failed to save results", 277 - details: error instanceof Error ? error.message : "Unknown error", 278 - }), 279 - }; 280 - } 202 + return successResponse({ 203 + success: true, 204 + uploadId, 205 + totalUsers: results.length, 206 + matchedUsers: matchedCount, 207 + unmatchedUsers: results.length - matchedCount, 208 + }); 281 209 }; 210 + 211 + export const handler = withAuthErrorHandling(saveResultsHandler);
+19 -18
netlify/functions/session-manager.ts netlify/functions/shared/services/session/SessionService.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 - import { createOAuthClient } from "./client"; 3 - import { userSessions } from "./oauth-stores-db"; 2 + import { createOAuthClient } from "../oauth/client.factory"; 3 + import { userSessions } from "./stores"; 4 4 import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 5 + import { AuthenticationError, ERROR_MESSAGES } from "../../constants/errors"; 5 6 6 7 /** 7 8 * Session Manager - Coordinates between user sessions and OAuth sessions 8 9 * Provides a clean interface for session operations across the application 9 - */ 10 - export class SessionManager { 10 + **/ 11 + export class SessionService { 11 12 /** 12 13 * Get an authenticated Agent for a given session ID 13 14 * Handles both user session lookup and OAuth session restoration 14 - */ 15 + **/ 15 16 static async getAgentForSession(sessionId: string): Promise<{ 16 17 agent: Agent; 17 18 did: string; 18 19 client: NodeOAuthClient; 19 20 }> { 20 - console.log("[SessionManager] Getting agent for session:", sessionId); 21 + console.log("[SessionService] Getting agent for session:", sessionId); 21 22 22 23 // Get user session 23 24 const userSession = await userSessions.get(sessionId); 24 25 if (!userSession) { 25 - throw new Error("Invalid or expired session"); 26 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 26 27 } 27 28 28 29 const did = userSession.did; 29 - console.log("[SessionManager] Found user session for DID:", did); 30 + console.log("[SessionService] Found user session for DID:", did); 30 31 31 32 // Create OAuth client 32 33 const client = await createOAuthClient(); 33 34 34 35 // Restore OAuth session 35 36 const oauthSession = await client.restore(did); 36 - console.log("[SessionManager] Restored OAuth session for DID:", did); 37 + console.log("[SessionService] Restored OAuth session for DID:", did); 37 38 38 39 // Create agent from OAuth session 39 40 const agent = new Agent(oauthSession); ··· 44 45 /** 45 46 * Delete a session and clean up associated OAuth sessions 46 47 * Ensures both user_sessions and oauth_sessions are cleaned up 47 - */ 48 + **/ 48 49 static async deleteSession(sessionId: string): Promise<void> { 49 - console.log("[SessionManager] Deleting session:", sessionId); 50 + console.log("[SessionService] Deleting session:", sessionId); 50 51 51 52 // Get user session first 52 53 const userSession = await userSessions.get(sessionId); 53 54 if (!userSession) { 54 - console.log("[SessionManager] Session not found:", sessionId); 55 + console.log("[SessionService] Session not found:", sessionId); 55 56 return; 56 57 } 57 58 ··· 63 64 64 65 // Try to revoke at the PDS (this also deletes from oauth_sessions) 65 66 await client.revoke(did); 66 - console.log("[SessionManager] Revoked OAuth session for DID:", did); 67 + console.log("[SessionService] Revoked OAuth session for DID:", did); 67 68 } catch (error) { 68 69 // If revocation fails, the OAuth session might already be invalid 69 - console.log("[SessionManager] Could not revoke OAuth session:", error); 70 + console.log("[SessionService] Could not revoke OAuth session:", error); 70 71 } 71 72 72 73 // Delete user session 73 74 await userSessions.del(sessionId); 74 - console.log("[SessionManager] Deleted user session:", sessionId); 75 + console.log("[SessionService] Deleted user session:", sessionId); 75 76 } 76 77 77 78 /** 78 79 * Verify a session exists and is valid 79 - */ 80 + **/ 80 81 static async verifySession(sessionId: string): Promise<boolean> { 81 82 const userSession = await userSessions.get(sessionId); 82 - return userSession !== null; 83 + return userSession !== undefined; 83 84 } 84 85 85 86 /** 86 87 * Get the DID for a session without creating an agent 87 - */ 88 + **/ 88 89 static async getDIDForSession(sessionId: string): Promise<string | null> { 89 90 const userSession = await userSessions.get(sessionId); 90 91 return userSession?.did || null;
+60 -113
netlify/functions/session.ts
··· 1 - import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 - import { SessionManager } from "./session-manager"; 3 - import cookie from "cookie"; 1 + import { SimpleHandler } from "./shared/types/api.types"; 2 + import { SessionService } from "./shared/services/session"; 3 + import { extractSessionId } from "./shared/middleware"; 4 + import { successResponse } from "./shared/utils"; 5 + import { withErrorHandling } from "./shared/middleware"; 6 + import { AuthenticationError, ERROR_MESSAGES } from "./shared/constants/errors"; 4 7 5 8 // In-memory cache for profile 6 9 const profileCache = new Map<string, { data: any; timestamp: number }>(); 7 10 const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 8 11 9 - export const handler: Handler = async ( 10 - event: HandlerEvent, 11 - ): Promise<HandlerResponse> => { 12 - try { 13 - const cookies = event.headers.cookie 14 - ? cookie.parse(event.headers.cookie) 15 - : {}; 16 - const sessionId = 17 - event.queryStringParameters?.session || cookies.atlast_session; 12 + const sessionHandler: SimpleHandler = async (event) => { 13 + const sessionId = 14 + event.queryStringParameters?.session || extractSessionId(event); 18 15 19 - if (!sessionId) { 20 - return { 21 - statusCode: 401, 22 - headers: { "Content-Type": "application/json" }, 23 - body: JSON.stringify({ error: "No session" }), 24 - }; 25 - } 16 + if (!sessionId) { 17 + throw new AuthenticationError(ERROR_MESSAGES.NO_SESSION_COOKIE); 18 + } 26 19 27 - // Verify session exists 28 - const isValid = await SessionManager.verifySession(sessionId); 29 - if (!isValid) { 30 - return { 31 - statusCode: 401, 32 - headers: { "Content-Type": "application/json" }, 33 - body: JSON.stringify({ error: "Invalid or expired session" }), 34 - }; 35 - } 20 + // Verify session exists 21 + const isValid = await SessionService.verifySession(sessionId); 22 + if (!isValid) { 23 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 24 + } 36 25 37 - // Get DID from session 38 - const did = await SessionManager.getDIDForSession(sessionId); 39 - if (!did) { 40 - return { 41 - statusCode: 401, 42 - headers: { "Content-Type": "application/json" }, 43 - body: JSON.stringify({ error: "Invalid session" }), 44 - }; 45 - } 26 + // Get DID from session 27 + const did = await SessionService.getDIDForSession(sessionId); 28 + if (!did) { 29 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 30 + } 46 31 47 - const now = Date.now(); 32 + const now = Date.now(); 48 33 49 - // Check profile cache 50 - const cached = profileCache.get(did); 51 - if (cached && now - cached.timestamp < PROFILE_CACHE_TTL) { 52 - console.log("Returning cached profile for", did); 34 + // Check profile cache 35 + const cached = profileCache.get(did); 36 + if (cached && now - cached.timestamp < PROFILE_CACHE_TTL) { 37 + console.log("Returning cached profile for", did); 38 + return successResponse(cached.data, 200, { 39 + "Cache-Control": "private, max-age=300", 40 + "X-Cache-Status": "HIT", 41 + }); 42 + } 53 43 54 - return { 55 - statusCode: 200, 56 - headers: { 57 - "Content-Type": "application/json", 58 - "Access-Control-Allow-Origin": "*", 59 - "Cache-Control": "private, max-age=300", // Browser can cache for 5 minutes 60 - "X-Cache-Status": "HIT", 61 - }, 62 - body: JSON.stringify(cached.data), 63 - }; 64 - } 44 + // Cache miss - fetch full profile 45 + const { agent } = await SessionService.getAgentForSession(sessionId); 65 46 66 - // Cache miss - fetch full profile 67 - try { 68 - // Get authenticated agent using SessionManager 69 - const { agent } = await SessionManager.getAgentForSession(sessionId); 47 + // Get profile - throw error if this fails 48 + const profile = await agent.getProfile({ actor: did }); 70 49 71 - // Get profile 72 - const profile = await agent.getProfile({ actor: did }); 50 + const profileData = { 51 + did: did, 52 + handle: profile.data.handle, 53 + displayName: profile.data.displayName, 54 + avatar: profile.data.avatar, 55 + description: profile.data.description, 56 + }; 73 57 74 - const profileData = { 75 - did: did, 76 - handle: profile.data.handle, 77 - displayName: profile.data.displayName, 78 - avatar: profile.data.avatar, 79 - description: profile.data.description, 80 - }; 58 + // Cache the profile data 59 + profileCache.set(did, { 60 + data: profileData, 61 + timestamp: now, 62 + }); 81 63 82 - // Cache the profile data 83 - profileCache.set(did, { 84 - data: profileData, 85 - timestamp: now, 86 - }); 87 - 88 - // Clean up old profile cache entries 89 - if (profileCache.size > 100) { 90 - for (const [cachedDid, entry] of profileCache.entries()) { 91 - if (now - entry.timestamp > PROFILE_CACHE_TTL) { 92 - profileCache.delete(cachedDid); 93 - } 94 - } 64 + // Clean up old profile cache entries 65 + if (profileCache.size > 100) { 66 + for (const [cachedDid, entry] of profileCache.entries()) { 67 + if (now - entry.timestamp > PROFILE_CACHE_TTL) { 68 + profileCache.delete(cachedDid); 95 69 } 96 - 97 - return { 98 - statusCode: 200, 99 - headers: { 100 - "Content-Type": "application/json", 101 - "Access-Control-Allow-Origin": "*", 102 - "Cache-Control": "private, max-age=300", 103 - "X-Cache-Status": "MISS", 104 - }, 105 - body: JSON.stringify(profileData), 106 - }; 107 - } catch (error) { 108 - console.error("Profile fetch error:", error); 109 - 110 - // If profile fetch fails, return basic session info 111 - return { 112 - statusCode: 200, 113 - headers: { 114 - "Content-Type": "application/json", 115 - "Access-Control-Allow-Origin": "*", 116 - "X-Cache-Status": "ERROR", 117 - }, 118 - body: JSON.stringify({ 119 - did: did, 120 - // Profile data unavailable 121 - }), 122 - }; 123 70 } 124 - } catch (error) { 125 - console.error("Session error:", error); 126 - return { 127 - statusCode: 500, 128 - headers: { "Content-Type": "application/json" }, 129 - body: JSON.stringify({ error: "Internal server error" }), 130 - }; 131 71 } 72 + 73 + return successResponse(profileData, 200, { 74 + "Cache-Control": "private, max-age=300", 75 + "X-Cache-Status": "MISS", 76 + }); 132 77 }; 78 + 79 + export const handler = withErrorHandling(sessionHandler);
+50
netlify/functions/shared/constants/errors.ts
··· 1 + export class ApiError extends Error { 2 + constructor( 3 + message: string, 4 + public statusCode: number = 500, 5 + public details?: string, 6 + ) { 7 + super(message); 8 + this.name = "ApiError"; 9 + } 10 + } 11 + 12 + export class AuthenticationError extends ApiError { 13 + constructor(message: string = "Authentication required", details?: string) { 14 + super(message, 401, details); 15 + this.name = "AuthenticationError"; 16 + } 17 + } 18 + 19 + export class ValidationError extends ApiError { 20 + constructor(message: string, details?: string) { 21 + super(message, 400, details); 22 + this.name = "ValidationError"; 23 + } 24 + } 25 + 26 + export class NotFoundError extends ApiError { 27 + constructor(message: string = "Resource not found", details?: string) { 28 + super(message, 404, details); 29 + this.name = "NotFoundError"; 30 + } 31 + } 32 + 33 + export class DatabaseError extends ApiError { 34 + constructor(message: string = "Database operation failed", details?: string) { 35 + super(message, 500, details); 36 + this.name = "DatabaseError"; 37 + } 38 + } 39 + 40 + export const ERROR_MESSAGES = { 41 + NO_SESSION_COOKIE: "No session cookie", 42 + INVALID_SESSION: "Invalid or expired session", 43 + MISSING_PARAMETERS: "Missing required parameters", 44 + OAUTH_FAILED: "OAuth authentication failed", 45 + DATABASE_ERROR: "Database operation failed", 46 + PROFILE_FETCH_FAILED: "Failed to fetch profile", 47 + SEARCH_FAILED: "Search operation failed", 48 + FOLLOW_FAILED: "Follow operation failed", 49 + SAVE_FAILED: "Failed to save results", 50 + } as const;
+8
netlify/functions/shared/constants/index.ts
··· 1 + export * from "./errors"; 2 + 3 + export const CONFIG = { 4 + PROFILE_CACHE_TTL: 5 * 60 * 1000, // 5 minutes 5 + SESSION_EXPIRY: 30 * 24 * 60 * 60 * 1000, // 30 days 6 + STATE_EXPIRY: 10 * 60 * 1000, // 10 minutes 7 + COOKIE_MAX_AGE: 2592000, // 30 days in seconds 8 + } as const;
+7
netlify/functions/shared/index.ts
··· 1 + export * from "./types"; 2 + export * from "./constants"; 3 + export * from "./utils"; 4 + export * from "./middleware"; 5 + export * from "./services/database"; 6 + export * from "./services/session"; 7 + export * from "./services/oauth";
+46
netlify/functions/shared/middleware/auth.middleware.ts
··· 1 + import { HandlerEvent } from "@netlify/functions"; 2 + import cookie from "cookie"; 3 + import { userSessions } from "../services/session/stores"; 4 + import { AuthenticationError, ERROR_MESSAGES } from "../constants/errors"; 5 + import { AuthenticatedContext } from "../types"; 6 + 7 + /** 8 + * Middleware to extract and validate session from cookies 9 + * Throws AuthenticationError if session is invalid 10 + **/ 11 + export async function authenticateRequest( 12 + event: HandlerEvent, 13 + ): Promise<AuthenticatedContext> { 14 + // Parse cookies 15 + const cookies = event.headers.cookie 16 + ? cookie.parse(event.headers.cookie) 17 + : {}; 18 + const sessionId = cookies.atlast_session; 19 + 20 + if (!sessionId) { 21 + throw new AuthenticationError(ERROR_MESSAGES.NO_SESSION_COOKIE); 22 + } 23 + 24 + // Validate session 25 + const userSession = await userSessions.get(sessionId); 26 + if (!userSession) { 27 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 28 + } 29 + 30 + return { 31 + sessionId, 32 + did: userSession.did, 33 + event, 34 + }; 35 + } 36 + 37 + /** 38 + * Extract session ID from cookies without validation 39 + * Returns null if no session cookie exists 40 + **/ 41 + export function extractSessionId(event: HandlerEvent): string | null { 42 + const cookies = event.headers.cookie 43 + ? cookie.parse(event.headers.cookie) 44 + : {}; 45 + return cookies.atlast_session || null; 46 + }
+55
netlify/functions/shared/middleware/error.middleware.ts
··· 1 + import { HandlerEvent, HandlerResponse, Handler } from "@netlify/functions"; 2 + import { ApiError } from "../constants/errors"; 3 + import { errorResponse } from "../utils/response.utils"; 4 + import { SimpleHandler, AuthenticatedHandler } from "../types"; 5 + 6 + /** 7 + * Wraps a handler function with standardized error handling 8 + * Catches all errors and returns appropriate error responses 9 + **/ 10 + export function withErrorHandling(handler: SimpleHandler): Handler { 11 + return async (event: HandlerEvent): Promise<HandlerResponse> => { 12 + try { 13 + return await handler(event); 14 + } catch (error) { 15 + console.error("Handler error:", error); 16 + 17 + if (error instanceof ApiError) { 18 + return errorResponse(error.message, error.statusCode, error.details); 19 + } 20 + 21 + // Unknown errors 22 + return errorResponse( 23 + "Internal server error", 24 + 500, 25 + error instanceof Error ? error.message : "Unknown error", 26 + ); 27 + } 28 + }; 29 + } 30 + 31 + /** 32 + * Wraps an authenticated handler with error handling 33 + **/ 34 + export function withAuthErrorHandling(handler: AuthenticatedHandler): Handler { 35 + return async (event: HandlerEvent): Promise<HandlerResponse> => { 36 + try { 37 + // Import here to avoid circular dependency 38 + const { authenticateRequest } = await import("./auth.middleware"); 39 + const context = await authenticateRequest(event); 40 + return await handler(context); 41 + } catch (error) { 42 + console.error("Authenticated handler error:", error); 43 + 44 + if (error instanceof ApiError) { 45 + return errorResponse(error.message, error.statusCode, error.details); 46 + } 47 + 48 + return errorResponse( 49 + "Internal server error", 50 + 500, 51 + error instanceof Error ? error.message : "Unknown error", 52 + ); 53 + } 54 + }; 55 + }
+2
netlify/functions/shared/middleware/index.ts
··· 1 + export * from "./auth.middleware"; 2 + export * from "./error.middleware";
+22
netlify/functions/shared/repositories/BaseRepository.ts
··· 1 + import { getDbClient } from "../services/database/connection"; 2 + import { NeonQueryFunction } from "@neondatabase/serverless"; 3 + 4 + /** 5 + * Base repository class providing common database access patterns 6 + **/ 7 + export abstract class BaseRepository { 8 + protected sql: NeonQueryFunction<any, any>; 9 + 10 + constructor() { 11 + this.sql = getDbClient(); 12 + } 13 + 14 + /** 15 + * Execute a raw query 16 + **/ 17 + protected async query<T>( 18 + queryFn: (sql: NeonQueryFunction<any, any>) => Promise<T>, 19 + ): Promise<T> { 20 + return await queryFn(this.sql); 21 + } 22 + }
+237
netlify/functions/shared/repositories/MatchRepository.ts
··· 1 + import { BaseRepository } from "./BaseRepository"; 2 + import { AtprotoMatchRow } from "../types"; 3 + 4 + export class MatchRepository extends BaseRepository { 5 + /** 6 + * Store a single atproto match 7 + **/ 8 + async storeMatch( 9 + sourceAccountId: number, 10 + atprotoDid: string, 11 + atprotoHandle: string, 12 + atprotoDisplayName: string | undefined, 13 + atprotoAvatar: string | undefined, 14 + matchScore: number, 15 + postCount: number, 16 + followerCount: number, 17 + followStatus?: Record<string, boolean>, 18 + ): Promise<number> { 19 + const result = await this.sql` 20 + INSERT INTO atproto_matches ( 21 + source_account_id, atproto_did, atproto_handle, 22 + atproto_display_name, atproto_avatar, match_score, 23 + post_count, follower_count, follow_status 24 + ) 25 + VALUES ( 26 + ${sourceAccountId}, ${atprotoDid}, ${atprotoHandle}, 27 + ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore}, 28 + ${postCount || 0}, ${followerCount || 0}, ${JSON.stringify(followStatus || {})} 29 + ) 30 + ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 31 + atproto_handle = ${atprotoHandle}, 32 + atproto_display_name = ${atprotoDisplayName || null}, 33 + atproto_avatar = ${atprotoAvatar || null}, 34 + match_score = ${matchScore}, 35 + post_count = ${postCount}, 36 + follower_count = ${followerCount}, 37 + follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || ${JSON.stringify(followStatus || {})}, 38 + last_verified = NOW() 39 + RETURNING id 40 + `; 41 + 42 + return (result as any[])[0].id; 43 + } 44 + 45 + /** 46 + * Bulk store atproto matches 47 + **/ 48 + async bulkStoreMatches( 49 + matches: Array<{ 50 + sourceAccountId: number; 51 + atprotoDid: string; 52 + atprotoHandle: string; 53 + atprotoDisplayName?: string; 54 + atprotoAvatar?: string; 55 + atprotoDescription?: string; 56 + matchScore: number; 57 + postCount?: number; 58 + followerCount?: number; 59 + followStatus?: Record<string, boolean>; 60 + }>, 61 + ): Promise<Map<string, number>> { 62 + if (matches.length === 0) return new Map(); 63 + 64 + const sourceAccountId = matches.map((m) => m.sourceAccountId); 65 + const atprotoDid = matches.map((m) => m.atprotoDid); 66 + const atprotoHandle = matches.map((m) => m.atprotoHandle); 67 + const atprotoDisplayName = matches.map((m) => m.atprotoDisplayName || null); 68 + const atprotoAvatar = matches.map((m) => m.atprotoAvatar || null); 69 + const atprotoDescription = matches.map((m) => m.atprotoDescription || null); 70 + const matchScore = matches.map((m) => m.matchScore); 71 + const postCount = matches.map((m) => m.postCount || 0); 72 + const followerCount = matches.map((m) => m.followerCount || 0); 73 + const followStatus = matches.map((m) => 74 + JSON.stringify(m.followStatus || {}), 75 + ); 76 + 77 + const result = await this.sql` 78 + INSERT INTO atproto_matches ( 79 + source_account_id, atproto_did, atproto_handle, 80 + atproto_display_name, atproto_avatar, atproto_description, 81 + match_score, post_count, follower_count, follow_status 82 + ) 83 + SELECT * FROM UNNEST( 84 + ${sourceAccountId}::integer[], 85 + ${atprotoDid}::text[], 86 + ${atprotoHandle}::text[], 87 + ${atprotoDisplayName}::text[], 88 + ${atprotoAvatar}::text[], 89 + ${atprotoDescription}::text[], 90 + ${matchScore}::integer[], 91 + ${postCount}::integer[], 92 + ${followerCount}::integer[], 93 + ${followStatus}::jsonb[] 94 + ) AS t( 95 + source_account_id, atproto_did, atproto_handle, 96 + atproto_display_name, atproto_avatar, atproto_description, 97 + match_score, post_count, follower_count, follow_status 98 + ) 99 + ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 100 + atproto_handle = EXCLUDED.atproto_handle, 101 + atproto_display_name = EXCLUDED.atproto_display_name, 102 + atproto_avatar = EXCLUDED.atproto_avatar, 103 + atproto_description = EXCLUDED.atproto_description, 104 + match_score = EXCLUDED.match_score, 105 + post_count = EXCLUDED.post_count, 106 + follower_count = EXCLUDED.follower_count, 107 + follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || EXCLUDED.follow_status, 108 + last_verified = NOW() 109 + RETURNING id, source_account_id, atproto_did 110 + `; 111 + 112 + // Create map of "sourceAccountId:atprotoDid" to match ID 113 + const idMap = new Map<string, number>(); 114 + for (const row of result as any[]) { 115 + idMap.set(`${row.source_account_id}:${row.atproto_did}`, row.id); 116 + } 117 + 118 + return idMap; 119 + } 120 + 121 + /** 122 + * Get upload details with pagination 123 + **/ 124 + async getUploadDetails( 125 + uploadId: string, 126 + did: string, 127 + page: number, 128 + pageSize: number, 129 + ): Promise<{ 130 + results: any[]; 131 + totalUsers: number; 132 + }> { 133 + // First verify upload belongs to user and get total count 134 + const uploadCheck = await this.sql` 135 + SELECT upload_id, total_users FROM user_uploads 136 + WHERE upload_id = ${uploadId} AND did = ${did} 137 + `; 138 + 139 + if ((uploadCheck as any[]).length === 0) { 140 + return { results: [], totalUsers: 0 }; 141 + } 142 + 143 + const totalUsers = (uploadCheck as any[])[0].total_users; 144 + const offset = (page - 1) * pageSize; 145 + 146 + // Fetch paginated results 147 + const results = await this.sql` 148 + SELECT 149 + sa.source_username, 150 + sa.normalized_username, 151 + usf.source_date, 152 + am.atproto_did, 153 + am.atproto_handle, 154 + am.atproto_display_name, 155 + am.atproto_avatar, 156 + am.atproto_description, 157 + am.match_score, 158 + am.post_count, 159 + am.follower_count, 160 + am.found_at, 161 + am.follow_status, 162 + am.last_follow_check, 163 + ums.followed, 164 + ums.dismissed, 165 + CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match 166 + FROM user_source_follows usf 167 + JOIN source_accounts sa ON usf.source_account_id = sa.id 168 + JOIN user_uploads uu ON usf.upload_id = uu.upload_id 169 + LEFT JOIN atproto_matches am ON sa.id = am.source_account_id AND am.is_active = true 170 + LEFT JOIN user_match_status ums ON am.id = ums.atproto_match_id AND ums.did = ${did} 171 + WHERE usf.upload_id = ${uploadId} 172 + ORDER BY 173 + CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END, 174 + is_new_match DESC, 175 + am.post_count DESC NULLS LAST, 176 + am.follower_count DESC NULLS LAST, 177 + sa.source_username 178 + LIMIT ${pageSize} 179 + OFFSET ${offset} 180 + `; 181 + 182 + return { 183 + results: results as any[], 184 + totalUsers, 185 + }; 186 + } 187 + 188 + /** 189 + * Update follow status for a match 190 + **/ 191 + async updateFollowStatus( 192 + atprotoDid: string, 193 + followLexicon: string, 194 + isFollowing: boolean, 195 + ): Promise<void> { 196 + await this.sql` 197 + UPDATE atproto_matches 198 + SET follow_status = follow_status || jsonb_build_object(${followLexicon}, ${isFollowing}), 199 + last_follow_check = NOW() 200 + WHERE atproto_did = ${atprotoDid} 201 + `; 202 + } 203 + 204 + /** 205 + * Create or update user match status 206 + **/ 207 + async upsertUserMatchStatus( 208 + statuses: Array<{ 209 + did: string; 210 + atprotoMatchId: number; 211 + sourceAccountId: number; 212 + viewed: boolean; 213 + }>, 214 + ): Promise<void> { 215 + if (statuses.length === 0) return; 216 + 217 + const did = statuses.map((s) => s.did); 218 + const atprotoMatchId = statuses.map((s) => s.atprotoMatchId); 219 + const sourceAccountId = statuses.map((s) => s.sourceAccountId); 220 + const viewedFlags = statuses.map((s) => s.viewed); 221 + const viewedDates = statuses.map((s) => (s.viewed ? new Date() : null)); 222 + 223 + await this.sql` 224 + INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) 225 + SELECT * FROM UNNEST( 226 + ${did}::text[], 227 + ${atprotoMatchId}::integer[], 228 + ${sourceAccountId}::integer[], 229 + ${viewedFlags}::boolean[], 230 + ${viewedDates}::timestamp[] 231 + ) AS t(did, atproto_match_id, source_account_id, viewed, viewed_at) 232 + ON CONFLICT (did, atproto_match_id) DO UPDATE SET 233 + viewed = EXCLUDED.viewed, 234 + viewed_at = CASE WHEN EXCLUDED.viewed THEN NOW() ELSE user_match_status.viewed_at END 235 + `; 236 + } 237 + }
+104
netlify/functions/shared/repositories/SourceAccountRepository.ts
··· 1 + import { BaseRepository } from "./BaseRepository"; 2 + import { SourceAccountRow } from "../types"; 3 + 4 + export class SourceAccountRepository extends BaseRepository { 5 + /** 6 + * Get or create a source account 7 + **/ 8 + async getOrCreate( 9 + sourcePlatform: string, 10 + sourceUsername: string, 11 + ): Promise<number> { 12 + const normalized = sourceUsername.toLowerCase().replace(/[._-]/g, ""); 13 + 14 + const result = await this.sql` 15 + INSERT INTO source_accounts (source_platform, source_username, normalized_username) 16 + VALUES (${sourcePlatform}, ${sourceUsername}, ${normalized}) 17 + ON CONFLICT (source_platform, normalized_username) DO UPDATE SET 18 + source_username = ${sourceUsername} 19 + RETURNING id 20 + `; 21 + 22 + return (result as any[])[0].id; 23 + } 24 + 25 + /** 26 + * Bulk create source accounts 27 + **/ 28 + async bulkCreate( 29 + sourcePlatform: string, 30 + usernames: string[], 31 + ): Promise<Map<string, number>> { 32 + const values = usernames.map((username) => ({ 33 + platform: sourcePlatform, 34 + username: username, 35 + normalized: username.toLowerCase().replace(/[._-]/g, ""), 36 + })); 37 + 38 + const platforms = values.map((v) => v.platform); 39 + const source_usernames = values.map((v) => v.username); 40 + const normalized = values.map((v) => v.normalized); 41 + 42 + const result = await this.sql` 43 + INSERT INTO source_accounts (source_platform, source_username, normalized_username) 44 + SELECT * 45 + FROM UNNEST( 46 + ${platforms}::text[], 47 + ${source_usernames}::text[], 48 + ${normalized}::text[] 49 + ) AS t(source_platform, source_username, normalized_username) 50 + ON CONFLICT (source_platform, normalized_username) DO UPDATE 51 + SET source_username = EXCLUDED.source_username 52 + RETURNING id, normalized_username 53 + `; 54 + 55 + // Create map of normalized username to ID 56 + const idMap = new Map<string, number>(); 57 + for (const row of result as any[]) { 58 + idMap.set(row.normalized_username, row.id); 59 + } 60 + 61 + return idMap; 62 + } 63 + 64 + /** 65 + * Mark source accounts as matched 66 + **/ 67 + async markAsMatched(sourceAccountIds: number[]): Promise<void> { 68 + if (sourceAccountIds.length === 0) return; 69 + 70 + await this.sql` 71 + UPDATE source_accounts 72 + SET match_found = true, match_found_at = NOW() 73 + WHERE id = ANY(${sourceAccountIds}) 74 + `; 75 + } 76 + 77 + /** 78 + * Link user to source accounts 79 + **/ 80 + async linkUserToAccounts( 81 + uploadId: string, 82 + did: string, 83 + links: Array<{ sourceAccountId: number; sourceDate: string }>, 84 + ): Promise<void> { 85 + const numLinks = links.length; 86 + if (numLinks === 0) return; 87 + 88 + const sourceAccountIds = links.map((l) => l.sourceAccountId); 89 + const sourceDates = links.map((l) => l.sourceDate); 90 + const uploadIds = Array(numLinks).fill(uploadId); 91 + const dids = Array(numLinks).fill(did); 92 + 93 + await this.sql` 94 + INSERT INTO user_source_follows (upload_id, did, source_account_id, source_date) 95 + SELECT * FROM UNNEST( 96 + ${uploadIds}::text[], 97 + ${dids}::text[], 98 + ${sourceAccountIds}::integer[], 99 + ${sourceDates}::text[] 100 + ) AS t(upload_id, did, source_account_id, source_date) 101 + ON CONFLICT (upload_id, source_account_id) DO NOTHING 102 + `; 103 + } 104 + }
+85
netlify/functions/shared/repositories/UploadRepository.ts
··· 1 + import { BaseRepository } from "./BaseRepository"; 2 + import { UserUploadRow } from "../types"; 3 + 4 + export class UploadRepository extends BaseRepository { 5 + /** 6 + * Create a new upload record 7 + **/ 8 + async createUpload( 9 + uploadId: string, 10 + did: string, 11 + sourcePlatform: string, 12 + totalUsers: number, 13 + matchedUsers: number, 14 + ): Promise<void> { 15 + await this.sql` 16 + INSERT INTO user_uploads (upload_id, did, source_platform, total_users, matched_users, unmatched_users) 17 + VALUES (${uploadId}, ${did}, ${sourcePlatform}, ${totalUsers}, ${matchedUsers}, ${totalUsers - matchedUsers}) 18 + ON CONFLICT (upload_id) DO NOTHING 19 + `; 20 + } 21 + 22 + /** 23 + * Get all uploads for a user 24 + **/ 25 + async getUserUploads(did: string): Promise<UserUploadRow[]> { 26 + const result = await this.sql` 27 + SELECT 28 + upload_id, 29 + source_platform, 30 + created_at, 31 + total_users, 32 + matched_users, 33 + unmatched_users 34 + FROM user_uploads 35 + WHERE did = ${did} 36 + ORDER BY created_at DESC 37 + `; 38 + return result as UserUploadRow[]; 39 + } 40 + 41 + /** 42 + * Get a specific upload 43 + **/ 44 + async getUpload( 45 + uploadId: string, 46 + did: string, 47 + ): Promise<UserUploadRow | null> { 48 + const result = await this.sql` 49 + SELECT * FROM user_uploads 50 + WHERE upload_id = ${uploadId} AND did = ${did} 51 + `; 52 + const rows = result as UserUploadRow[]; 53 + return rows[0] || null; 54 + } 55 + 56 + /** 57 + * Update upload match counts 58 + **/ 59 + async updateMatchCounts( 60 + uploadId: string, 61 + matchedUsers: number, 62 + unmatchedUsers: number, 63 + ): Promise<void> { 64 + await this.sql` 65 + UPDATE user_uploads 66 + SET matched_users = ${matchedUsers}, 67 + unmatched_users = ${unmatchedUsers} 68 + WHERE upload_id = ${uploadId} 69 + `; 70 + } 71 + 72 + /** 73 + * Check for recent uploads (within 5 seconds) 74 + **/ 75 + async hasRecentUpload(did: string): Promise<boolean> { 76 + const result = await this.sql` 77 + SELECT upload_id FROM user_uploads 78 + WHERE did = ${did} 79 + AND created_at > NOW() - INTERVAL '5 seconds' 80 + ORDER BY created_at DESC 81 + LIMIT 1 82 + `; 83 + return (result as any[]).length > 0; 84 + } 85 + }
+4
netlify/functions/shared/repositories/index.ts
··· 1 + export * from "./BaseRepository"; 2 + export * from "./UploadRepository"; 3 + export * from "./SourceAccountRepository"; 4 + export * from "./MatchRepository";
+229
netlify/functions/shared/services/database/DatabaseService.ts
··· 1 + import { getDbClient } from "./connection"; 2 + import { DatabaseError } from "../../constants/errors"; 3 + 4 + export class DatabaseService { 5 + private sql = getDbClient(); 6 + 7 + async initDatabase(): Promise<void> { 8 + try { 9 + console.log( 10 + "🧠 Connecting to DB:", 11 + process.env.NETLIFY_DATABASE_URL?.split("@")[1], 12 + ); 13 + 14 + // Test connection 15 + const res = (await this 16 + .sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`) as Record< 17 + string, 18 + any 19 + >[]; 20 + console.log("✅ Connected:", res[0]); 21 + 22 + // Create tables 23 + await this.createTables(); 24 + await this.createIndexes(); 25 + 26 + console.log("✅ Database initialized successfully"); 27 + } catch (error) { 28 + console.error("❌ Database initialization failed:", error); 29 + throw new DatabaseError( 30 + "Failed to initialize database", 31 + error instanceof Error ? error.message : "Unknown error", 32 + ); 33 + } 34 + } 35 + 36 + private async createTables(): Promise<void> { 37 + // OAuth Tables 38 + await this.sql` 39 + CREATE TABLE IF NOT EXISTS oauth_states ( 40 + key TEXT PRIMARY KEY, 41 + data JSONB NOT NULL, 42 + created_at TIMESTAMP DEFAULT NOW(), 43 + expires_at TIMESTAMP NOT NULL 44 + ) 45 + `; 46 + 47 + await this.sql` 48 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 49 + key TEXT PRIMARY KEY, 50 + data JSONB NOT NULL, 51 + created_at TIMESTAMP DEFAULT NOW(), 52 + expires_at TIMESTAMP NOT NULL 53 + ) 54 + `; 55 + 56 + await this.sql` 57 + CREATE TABLE IF NOT EXISTS user_sessions ( 58 + session_id TEXT PRIMARY KEY, 59 + did TEXT NOT NULL, 60 + created_at TIMESTAMP DEFAULT NOW(), 61 + expires_at TIMESTAMP NOT NULL 62 + ) 63 + `; 64 + 65 + // User + Match Tracking 66 + await this.sql` 67 + CREATE TABLE IF NOT EXISTS user_uploads ( 68 + upload_id TEXT PRIMARY KEY, 69 + did TEXT NOT NULL, 70 + source_platform TEXT NOT NULL, 71 + created_at TIMESTAMP DEFAULT NOW(), 72 + last_checked TIMESTAMP, 73 + total_users INTEGER NOT NULL, 74 + matched_users INTEGER DEFAULT 0, 75 + unmatched_users INTEGER DEFAULT 0 76 + ) 77 + `; 78 + 79 + await this.sql` 80 + CREATE TABLE IF NOT EXISTS source_accounts ( 81 + id SERIAL PRIMARY KEY, 82 + source_platform TEXT NOT NULL, 83 + source_username TEXT NOT NULL, 84 + normalized_username TEXT NOT NULL, 85 + last_checked TIMESTAMP, 86 + match_found BOOLEAN DEFAULT FALSE, 87 + match_found_at TIMESTAMP, 88 + created_at TIMESTAMP DEFAULT NOW(), 89 + UNIQUE(source_platform, normalized_username) 90 + ) 91 + `; 92 + 93 + await this.sql` 94 + CREATE TABLE IF NOT EXISTS user_source_follows ( 95 + id SERIAL PRIMARY KEY, 96 + upload_id TEXT NOT NULL REFERENCES user_uploads(upload_id) ON DELETE CASCADE, 97 + did TEXT NOT NULL, 98 + source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE, 99 + source_date TEXT, 100 + created_at TIMESTAMP DEFAULT NOW(), 101 + UNIQUE(upload_id, source_account_id) 102 + ) 103 + `; 104 + 105 + await this.sql` 106 + CREATE TABLE IF NOT EXISTS atproto_matches ( 107 + id SERIAL PRIMARY KEY, 108 + source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE, 109 + atproto_did TEXT NOT NULL, 110 + atproto_handle TEXT NOT NULL, 111 + atproto_display_name TEXT, 112 + atproto_avatar TEXT, 113 + atproto_description TEXT, 114 + post_count INTEGER, 115 + follower_count INTEGER, 116 + match_score INTEGER NOT NULL, 117 + found_at TIMESTAMP DEFAULT NOW(), 118 + last_verified TIMESTAMP, 119 + is_active BOOLEAN DEFAULT TRUE, 120 + follow_status JSONB DEFAULT '{}', 121 + last_follow_check TIMESTAMP, 122 + UNIQUE(source_account_id, atproto_did) 123 + ) 124 + `; 125 + 126 + await this.sql` 127 + CREATE TABLE IF NOT EXISTS user_match_status ( 128 + id SERIAL PRIMARY KEY, 129 + did TEXT NOT NULL, 130 + atproto_match_id INTEGER NOT NULL REFERENCES atproto_matches(id) ON DELETE CASCADE, 131 + source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE, 132 + notified BOOLEAN DEFAULT FALSE, 133 + notified_at TIMESTAMP, 134 + viewed BOOLEAN DEFAULT FALSE, 135 + viewed_at TIMESTAMP, 136 + followed BOOLEAN DEFAULT FALSE, 137 + followed_at TIMESTAMP, 138 + dismissed BOOLEAN DEFAULT FALSE, 139 + dismissed_at TIMESTAMP, 140 + UNIQUE(did, atproto_match_id) 141 + ) 142 + `; 143 + 144 + await this.sql` 145 + CREATE TABLE IF NOT EXISTS notification_queue ( 146 + id SERIAL PRIMARY KEY, 147 + did TEXT NOT NULL, 148 + new_matches_count INTEGER NOT NULL, 149 + created_at TIMESTAMP DEFAULT NOW(), 150 + sent BOOLEAN DEFAULT FALSE, 151 + sent_at TIMESTAMP, 152 + retry_count INTEGER DEFAULT 0, 153 + last_error TEXT 154 + ) 155 + `; 156 + } 157 + 158 + private async createIndexes(): Promise<void> { 159 + // Existing indexes 160 + await this 161 + .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 162 + await this 163 + .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; 164 + await this 165 + .sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_did ON user_source_follows(did)`; 166 + await this 167 + .sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_source ON user_source_follows(source_account_id)`; 168 + await this 169 + .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source ON atproto_matches(source_account_id)`; 170 + await this 171 + .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_did ON atproto_matches(atproto_did)`; 172 + await this 173 + .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_notified ON user_match_status(did, notified, viewed)`; 174 + await this 175 + .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`; 176 + await this 177 + .sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 178 + 179 + // Enhanced indexes 180 + await this 181 + .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`; 182 + await this 183 + .sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`; 184 + await this 185 + .sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)`; 186 + await this 187 + .sql`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)`; 188 + await this 189 + .sql`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at)`; 190 + await this 191 + .sql`CREATE INDEX IF NOT EXISTS idx_user_uploads_did_created ON user_uploads(did, created_at DESC)`; 192 + await this 193 + .sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_upload_created ON user_source_follows(upload_id, source_account_id)`; 194 + await this 195 + .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_match_id ON user_match_status(atproto_match_id)`; 196 + await this 197 + .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source_active ON atproto_matches(source_account_id, is_active) WHERE is_active = true`; 198 + await this 199 + .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`; 200 + await this 201 + .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_status ON atproto_matches USING gin(follow_status)`; 202 + await this 203 + .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_check ON atproto_matches(last_follow_check)`; 204 + 205 + console.log("✅ Database indexes created/verified"); 206 + } 207 + 208 + async cleanupExpiredSessions(): Promise<void> { 209 + try { 210 + const statesDeleted = await this 211 + .sql`DELETE FROM oauth_states WHERE expires_at < NOW()`; 212 + const sessionsDeleted = await this 213 + .sql`DELETE FROM oauth_sessions WHERE expires_at < NOW()`; 214 + const userSessionsDeleted = await this 215 + .sql`DELETE FROM user_sessions WHERE expires_at < NOW()`; 216 + 217 + console.log("🧹 Cleanup:", { 218 + states: (statesDeleted as any).length, 219 + sessions: (sessionsDeleted as any).length, 220 + userSessions: (userSessionsDeleted as any).length, 221 + }); 222 + } catch (error) { 223 + throw new DatabaseError( 224 + "Failed to cleanup expired sessions", 225 + error instanceof Error ? error.message : "Unknown error", 226 + ); 227 + } 228 + } 229 + }
+41
netlify/functions/shared/services/database/connection.ts
··· 1 + import { neon, NeonQueryFunction } from "@neondatabase/serverless"; 2 + import { DatabaseError } from "../../constants/errors"; 3 + 4 + let sqlInstance: NeonQueryFunction<any, any> | undefined = undefined; 5 + let connectionInitialized = false; 6 + 7 + export function getDbClient(): NeonQueryFunction<any, any> { 8 + if (!sqlInstance) { 9 + if (!process.env.NETLIFY_DATABASE_URL) { 10 + throw new DatabaseError( 11 + "Database connection string not configured", 12 + "NETLIFY_DATABASE_URL environment variable is missing", 13 + ); 14 + } 15 + 16 + try { 17 + sqlInstance = neon(process.env.NETLIFY_DATABASE_URL); 18 + connectionInitialized = true; 19 + if (process.env.NODE_ENV !== "production") { 20 + console.log("✅ Database connection initialized"); 21 + } 22 + } catch (error) { 23 + throw new DatabaseError( 24 + "Failed to initialize database connection", 25 + error instanceof Error ? error.message : "Unknown error", 26 + ); 27 + } 28 + } 29 + 30 + return sqlInstance; 31 + } 32 + 33 + export function isConnectionInitialized(): boolean { 34 + return connectionInitialized; 35 + } 36 + 37 + // Reset connection (useful for testing) 38 + export function resetConnection(): void { 39 + sqlInstance = undefined; 40 + connectionInitialized = false; 41 + }
+2
netlify/functions/shared/services/database/index.ts
··· 1 + export * from "./connection"; 2 + export * from "./DatabaseService";
+75
netlify/functions/shared/services/oauth/config.ts
··· 1 + import { OAuthConfig } from "../../types"; 2 + 3 + export function getOAuthConfig(event?: { 4 + headers: Record<string, string | undefined>; 5 + }): OAuthConfig { 6 + let baseUrl: string | undefined; 7 + let deployContext: string | undefined; 8 + 9 + if (event?.headers) { 10 + // Get deploy context from Netlify headers 11 + deployContext = event.headers["x-nf-deploy-context"]; 12 + 13 + // For Netlify deploys, construct URL from host header 14 + const host = event.headers.host; 15 + const forwardedProto = event.headers["x-forwarded-proto"] || "https"; 16 + 17 + if (host && !host.includes("localhost") && !host.includes("127.0.0.1")) { 18 + baseUrl = `${forwardedProto}://${host}`; 19 + } 20 + } 21 + 22 + // Fallback to environment variables (prioritize DEPLOY_URL over URL for preview deploys) 23 + if (!baseUrl) { 24 + baseUrl = process.env.DEPLOY_URL || process.env.URL; 25 + } 26 + 27 + console.log("🔍 OAuth Config:", { 28 + fromHost: event?.headers?.host, 29 + deployContext: deployContext || process.env.CONTEXT, 30 + baseUrl, 31 + envAvailable: { 32 + DEPLOY_URL: !!process.env.DEPLOY_URL, 33 + URL: !!process.env.URL, 34 + }, 35 + }); 36 + 37 + // Development: loopback client for local dev 38 + const isLocalhost = 39 + !baseUrl || baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1"); 40 + 41 + if (isLocalhost) { 42 + const port = process.env.PORT || "8888"; 43 + const clientId = `http://localhost?${new URLSearchParams([ 44 + [ 45 + "redirect_uri", 46 + `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 47 + ], 48 + ["scope", "atproto transition:generic"], 49 + ])}`; 50 + 51 + console.log("Using loopback OAuth for local development"); 52 + 53 + return { 54 + clientId: clientId, 55 + redirectUri: `http://127.0.0.1:${port}/.netlify/functions/oauth-callback`, 56 + jwksUri: undefined, 57 + clientType: "loopback", 58 + }; 59 + } 60 + 61 + // Production/Preview: discoverable client 62 + if (!baseUrl) { 63 + throw new Error("No public URL available for OAuth configuration"); 64 + } 65 + 66 + console.log("Using confidential OAuth client for:", baseUrl); 67 + 68 + return { 69 + clientId: `${baseUrl}/oauth-client-metadata.json`, 70 + redirectUri: `${baseUrl}/.netlify/functions/oauth-callback`, 71 + jwksUri: `${baseUrl}/.netlify/functions/jwks`, 72 + clientType: "discoverable", 73 + usePrivateKey: true, 74 + }; 75 + }
+2
netlify/functions/shared/services/oauth/index.ts
··· 1 + export * from "./config"; 2 + export * from "./client.factory";
+2
netlify/functions/shared/services/session/index.ts
··· 1 + export * from "./SessionService"; 2 + export * from "./stores";
+31
netlify/functions/shared/services/session/stores/SessionStore.ts
··· 1 + import { getDbClient } from "../../database/connection"; 2 + import { SessionData, OAuthSessionRow } from "../../../types"; 3 + import { CONFIG } from "../../../constants"; 4 + 5 + export class PostgresSessionStore { 6 + private sql = getDbClient(); 7 + 8 + async get(key: string): Promise<SessionData | undefined> { 9 + const result = await this.sql` 10 + SELECT data FROM oauth_sessions 11 + WHERE key = ${key} AND expires_at > NOW() 12 + `; 13 + const rows = result as OAuthSessionRow[]; 14 + return rows[0]?.data as SessionData | undefined; 15 + } 16 + 17 + async set(key: string, value: SessionData): Promise<void> { 18 + const expiresAt = new Date(Date.now() + CONFIG.SESSION_EXPIRY); 19 + await this.sql` 20 + INSERT INTO oauth_sessions (key, data, expires_at) 21 + VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt}) 22 + ON CONFLICT (key) DO UPDATE SET 23 + data = ${JSON.stringify(value)}, 24 + expires_at = ${expiresAt} 25 + `; 26 + } 27 + 28 + async del(key: string): Promise<void> { 29 + await this.sql`DELETE FROM oauth_sessions WHERE key = ${key}`; 30 + } 31 + }
+31
netlify/functions/shared/services/session/stores/StateStore.ts
··· 1 + import { getDbClient } from "../../database/connection"; 2 + import { StateData, OAuthStateRow } from "../../../types"; 3 + import { CONFIG } from "../../../constants"; 4 + 5 + export class PostgresStateStore { 6 + private sql = getDbClient(); 7 + 8 + async get(key: string): Promise<StateData | undefined> { 9 + const result = await this.sql` 10 + SELECT data FROM oauth_states 11 + WHERE key = ${key} AND expires_at > NOW() 12 + `; 13 + const rows = result as OAuthStateRow[]; 14 + return rows[0]?.data as StateData | undefined; 15 + } 16 + 17 + async set(key: string, value: StateData): Promise<void> { 18 + const expiresAt = new Date(Date.now() + CONFIG.STATE_EXPIRY); 19 + await this.sql` 20 + INSERT INTO oauth_states (key, data, expires_at) 21 + VALUES (${key}, ${JSON.stringify(value)}, ${expiresAt.toISOString()}) 22 + ON CONFLICT (key) DO UPDATE SET 23 + data = ${JSON.stringify(value)}, 24 + expires_at = ${expiresAt.toISOString()} 25 + `; 26 + } 27 + 28 + async del(key: string): Promise<void> { 29 + await this.sql`DELETE FROM oauth_states WHERE key = ${key}`; 30 + } 31 + }
+31
netlify/functions/shared/services/session/stores/UserSessionStore.ts
··· 1 + import { getDbClient } from "../../database/connection"; 2 + import { UserSessionData, UserSessionRow } from "../../../types"; 3 + import { CONFIG } from "../../../constants"; 4 + 5 + export class PostgresUserSessionStore { 6 + private sql = getDbClient(); 7 + 8 + async get(sessionId: string): Promise<UserSessionData | undefined> { 9 + const result = await this.sql` 10 + SELECT did FROM user_sessions 11 + WHERE session_id = ${sessionId} AND expires_at > NOW() 12 + `; 13 + const rows = result as UserSessionRow[]; 14 + return rows[0] ? { did: rows[0].did } : undefined; 15 + } 16 + 17 + async set(sessionId: string, data: UserSessionData): Promise<void> { 18 + const expiresAt = new Date(Date.now() + CONFIG.SESSION_EXPIRY); 19 + await this.sql` 20 + INSERT INTO user_sessions (session_id, did, expires_at) 21 + VALUES (${sessionId}, ${data.did}, ${expiresAt}) 22 + ON CONFLICT (session_id) DO UPDATE SET 23 + did = ${data.did}, 24 + expires_at = ${expiresAt} 25 + `; 26 + } 27 + 28 + async del(sessionId: string): Promise<void> { 29 + await this.sql`DELETE FROM user_sessions WHERE session_id = ${sessionId}`; 30 + } 31 + }
+12
netlify/functions/shared/services/session/stores/index.ts
··· 1 + export * from "./StateStore"; 2 + export * from "./SessionStore"; 3 + export * from "./UserSessionStore"; 4 + 5 + import { PostgresStateStore } from "./StateStore"; 6 + import { PostgresSessionStore } from "./SessionStore"; 7 + import { PostgresUserSessionStore } from "./UserSessionStore"; 8 + 9 + // Export singleton instances 10 + export const stateStore = new PostgresStateStore(); 11 + export const sessionStore = new PostgresSessionStore(); 12 + export const userSessions = new PostgresUserSessionStore();
+48
netlify/functions/shared/types/api.types.ts
··· 1 + import { HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 + 3 + // Request context with authenticated session 4 + export interface AuthenticatedContext { 5 + sessionId: string; 6 + did: string; 7 + event: HandlerEvent; 8 + } 9 + 10 + // Standard API response structure 11 + export interface ApiResponse<T = any> { 12 + success: boolean; 13 + data?: T; 14 + error?: string; 15 + details?: string; 16 + } 17 + 18 + // Session data structures 19 + export interface StateData { 20 + dpopKey: any; 21 + verifier: string; 22 + appState?: string; 23 + } 24 + 25 + export interface SessionData { 26 + dpopKey: any; 27 + tokenSet: any; 28 + } 29 + 30 + export interface UserSessionData { 31 + did: string; 32 + } 33 + 34 + // OAuth configuration 35 + export interface OAuthConfig { 36 + clientId: string; 37 + redirectUri: string; 38 + jwksUri?: string; 39 + clientType: "loopback" | "discoverable"; 40 + usePrivateKey?: boolean; 41 + } 42 + 43 + // Handler types 44 + export type SimpleHandler = (event: HandlerEvent) => Promise<HandlerResponse>; 45 + 46 + export type AuthenticatedHandler = ( 47 + context: AuthenticatedContext, 48 + ) => Promise<HandlerResponse>;
+102
netlify/functions/shared/types/database.types.ts
··· 1 + export interface OAuthStateRow { 2 + key: string; 3 + data: { 4 + dpopKey: any; 5 + verifier: string; 6 + appState?: string; 7 + }; 8 + created_at: Date; 9 + expires_at: Date; 10 + } 11 + 12 + export interface OAuthSessionRow { 13 + key: string; 14 + data: { 15 + dpopKey: any; 16 + tokenSet: any; 17 + }; 18 + created_at: Date; 19 + expires_at: Date; 20 + } 21 + 22 + export interface UserSessionRow { 23 + session_id: string; 24 + did: string; 25 + created_at: Date; 26 + expires_at: Date; 27 + } 28 + 29 + export interface UserUploadRow { 30 + upload_id: string; 31 + did: string; 32 + source_platform: string; 33 + created_at: Date; 34 + last_checked: Date | null; 35 + total_users: number; 36 + matched_users: number; 37 + unmatched_users: number; 38 + } 39 + 40 + export interface SourceAccountRow { 41 + id: number; 42 + source_platform: string; 43 + source_username: string; 44 + normalized_username: string; 45 + last_checked: Date | null; 46 + match_found: boolean; 47 + match_found_at: Date | null; 48 + created_at: Date; 49 + } 50 + 51 + export interface UserSourceFollowRow { 52 + id: number; 53 + upload_id: string; 54 + did: string; 55 + source_account_id: number; 56 + source_date: string; 57 + created_at: Date; 58 + } 59 + 60 + export interface AtprotoMatchRow { 61 + id: number; 62 + source_account_id: number; 63 + atproto_did: string; 64 + atproto_handle: string; 65 + atproto_display_name: string | null; 66 + atproto_avatar: string | null; 67 + atproto_description: string | null; 68 + post_count: number; 69 + follower_count: number; 70 + match_score: number; 71 + found_at: Date; 72 + last_verified: Date | null; 73 + is_active: boolean; 74 + follow_status: Record<string, boolean>; 75 + last_follow_check: Date | null; 76 + } 77 + 78 + export interface UserMatchStatusRow { 79 + id: number; 80 + did: string; 81 + atproto_match_id: number; 82 + source_account_id: number; 83 + notified: boolean; 84 + notified_at: Date | null; 85 + viewed: boolean; 86 + viewed_at: Date | null; 87 + followed: boolean; 88 + followed_at: Date | null; 89 + dismissed: boolean; 90 + dismissed_at: Date | null; 91 + } 92 + 93 + export interface NotificationQueueRow { 94 + id: number; 95 + did: string; 96 + new_matches_count: number; 97 + created_at: Date; 98 + sent: boolean; 99 + sent_at: Date | null; 100 + retry_count: number; 101 + last_error: string | null; 102 + }
+2
netlify/functions/shared/types/index.ts
··· 1 + export * from "./database.types"; 2 + export * from "./api.types";
+1
netlify/functions/shared/utils/index.ts
··· 1 + export * from "./response.utils";
+63
netlify/functions/shared/utils/response.utils.ts
··· 1 + import { HandlerResponse } from "@netlify/functions"; 2 + import { ApiResponse } from "../types"; 3 + 4 + export function successResponse<T>( 5 + data: T, 6 + statusCode: number = 200, 7 + additionalHeaders: Record<string, string> = {}, 8 + ): HandlerResponse { 9 + const response: ApiResponse<T> = { 10 + success: true, 11 + data, 12 + }; 13 + 14 + return { 15 + statusCode, 16 + headers: { 17 + "Content-Type": "application/json", 18 + "Access-Control-Allow-Origin": "*", 19 + ...additionalHeaders, 20 + }, 21 + body: JSON.stringify(response), 22 + }; 23 + } 24 + 25 + export function errorResponse( 26 + error: string, 27 + statusCode: number = 500, 28 + details?: string, 29 + ): HandlerResponse { 30 + const response: ApiResponse = { 31 + success: false, 32 + error, 33 + details, 34 + }; 35 + 36 + return { 37 + statusCode, 38 + headers: { 39 + "Content-Type": "application/json", 40 + "Access-Control-Allow-Origin": "*", 41 + }, 42 + body: JSON.stringify(response), 43 + }; 44 + } 45 + 46 + export function redirectResponse( 47 + location: string, 48 + setCookies?: string[], 49 + ): HandlerResponse { 50 + const headers: Record<string, string | string[]> = { 51 + Location: location, 52 + }; 53 + 54 + if (setCookies && setCookies.length > 0) { 55 + headers["Set-Cookie"] = setCookies; 56 + } 57 + 58 + return { 59 + statusCode: 302, 60 + headers: headers as HandlerResponse["headers"], 61 + body: "", 62 + }; 63 + }
public/favicon-png.ico public/favicon.ico
+30 -8
src/lib/apiClient/realApiClient.ts
··· 68 68 69 69 const cache = new ResponseCache(); 70 70 71 + /** 72 + * Unwrap the standardized API response format 73 + * New format: { success: true, data: {...} } 74 + * Old format: direct data 75 + */ 76 + function unwrapResponse<T>(response: any): T { 77 + if (response.success !== undefined && response.data !== undefined) { 78 + return response.data as T; 79 + } 80 + return response as T; 81 + } 82 + 71 83 export const apiClient = { 72 84 // OAuth and Authentication 73 85 async startOAuth(handle: string): Promise<{ url: string }> { ··· 87 99 throw new Error(errorData.error || "Failed to start OAuth flow"); 88 100 } 89 101 90 - return res.json(); 102 + const response = await res.json(); 103 + return unwrapResponse<{ url: string }>(response); 91 104 }, 92 105 93 106 async getSession(): Promise<{ ··· 113 126 throw new Error("No valid session"); 114 127 } 115 128 116 - const data = await res.json(); 129 + const response = await res.json(); 130 + const data = unwrapResponse<AtprotoSession>(response); 117 131 118 132 // Cache the session data for 5 minutes 119 133 cache.set(cacheKey, data, 5 * 60 * 1000); ··· 162 176 throw new Error("Failed to fetch uploads"); 163 177 } 164 178 165 - const data = await res.json(); 179 + const response = await res.json(); 180 + const data = unwrapResponse<any>(response); 166 181 167 182 // Cache uploads list for 2 minutes 168 183 cache.set(cacheKey, data, 2 * 60 * 1000); ··· 207 222 throw new Error("Failed to fetch upload details"); 208 223 } 209 224 210 - const data = await res.json(); 225 + const response = await res.json(); 226 + const data = unwrapResponse<any>(response); 211 227 212 228 // Cache upload details page for 10 minutes 213 229 cache.set(cacheKey, data, 10 * 60 * 1000); ··· 265 281 throw new Error("Failed to check follow status"); 266 282 } 267 283 268 - const data = await res.json(); 284 + const response = await res.json(); 285 + const data = unwrapResponse<{ followStatus: Record<string, boolean> }>( 286 + response, 287 + ); 269 288 270 289 // Cache for 2 minutes 271 290 cache.set(cacheKey, data.followStatus, 2 * 60 * 1000); ··· 301 320 throw new Error(`Batch search failed: ${res.status}`); 302 321 } 303 322 304 - const data = await res.json(); 323 + const response = await res.json(); 324 + const data = unwrapResponse<{ results: BatchSearchResult[] }>(response); 305 325 306 326 // Cache search results for 10 minutes 307 327 cache.set(cacheKey, data, 10 * 60 * 1000); ··· 332 352 throw new Error("Batch follow failed"); 333 353 } 334 354 335 - const data = await res.json(); 355 + const response = await res.json(); 356 + const data = unwrapResponse<any>(response); 336 357 337 358 // Invalidate caches after following 338 359 cache.invalidate("uploads"); ··· 370 391 }); 371 392 372 393 if (res.ok) { 373 - const data = await res.json(); 394 + const response = await res.json(); 395 + const data = unwrapResponse<SaveResultsResponse>(response); 374 396 console.log(`Successfully saved ${data.matchedUsers} matches`); 375 397 376 398 // Invalidate caches after saving