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

more refactoring oops

Changed files
+1347 -1305
netlify
src
+17 -51
netlify/functions/batch-follow-users.ts
··· 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"; 1 + import { AuthenticatedHandler } from "./core/types"; 2 + import { SessionService } from "./services/SessionService"; 3 + import { FollowService } from "./services/FollowService"; 4 + import { MatchRepository } from "./repositories"; 5 + import { successResponse } from "./utils"; 6 + import { withAuthErrorHandling } from "./core/middleware"; 7 + import { ValidationError } from "./core/errors"; 7 8 8 9 const batchFollowHandler: AuthenticatedHandler = async (context) => { 9 - // Parse request body 10 10 const body = JSON.parse(context.event.body || "{}"); 11 11 const dids: string[] = body.dids || []; 12 12 const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; ··· 15 15 throw new ValidationError("dids array is required and must not be empty"); 16 16 } 17 17 18 - // Limit batch size to prevent timeouts and respect rate limits 19 18 if (dids.length > 100) { 20 19 throw new ValidationError("Maximum 100 DIDs per batch"); 21 20 } 22 21 23 - // Get authenticated agent using SessionService 24 - const { agent } = await SessionService.getAgentForSession(context.sessionId); 25 - 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); 32 - 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 - }); 40 - 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); 46 - } 47 - } 22 + const { agent } = await SessionService.getAgentForSession( 23 + context.sessionId, 24 + context.event, 25 + ); 48 26 49 - cursor = response.data.cursor; 50 - hasMore = !!cursor; 51 - 52 - if (didsSet.size === 0) { 53 - break; 54 - } 55 - } 56 - } catch (error) { 57 - console.error("Error checking existing follows:", error); 58 - // Continue - we'll handle duplicates in the follow loop 59 - } 27 + const alreadyFollowing = await FollowService.getAlreadyFollowing( 28 + agent, 29 + context.did, 30 + dids, 31 + followLexicon, 32 + ); 60 33 61 - // Follow all users 62 34 const results = []; 63 35 let consecutiveErrors = 0; 64 36 const MAX_CONSECUTIVE_ERRORS = 3; 65 37 const matchRepo = new MatchRepository(); 66 38 67 39 for (const did of dids) { 68 - // Skip if already following 69 40 if (alreadyFollowing.has(did)) { 70 41 results.push({ 71 42 did, ··· 74 45 error: null, 75 46 }); 76 47 77 - // Update database follow status 78 48 try { 79 49 await matchRepo.updateFollowStatus(did, followLexicon, true); 80 50 } catch (dbError) { ··· 102 72 error: null, 103 73 }); 104 74 105 - // Update database follow status 106 75 try { 107 76 await matchRepo.updateFollowStatus(did, followLexicon, true); 108 77 } catch (dbError) { 109 78 console.error("Failed to update follow status in DB:", dbError); 110 79 } 111 80 112 - // Reset error counter on success 113 81 consecutiveErrors = 0; 114 82 } catch (error) { 115 83 consecutiveErrors++; ··· 121 89 error: error instanceof Error ? error.message : "Follow failed", 122 90 }); 123 91 124 - // If we hit rate limits, implement exponential backoff 125 92 if ( 126 93 error instanceof Error && 127 94 (error.message.includes("rate limit") || error.message.includes("429")) ··· 133 100 console.log(`Rate limit hit. Backing off for ${backoffDelay}ms...`); 134 101 await new Promise((resolve) => setTimeout(resolve, backoffDelay)); 135 102 } else if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 136 - // For other repeated errors, small backoff 137 103 await new Promise((resolve) => setTimeout(resolve, 500)); 138 104 } 139 105 }
+18 -46
netlify/functions/batch-search-actors.ts
··· 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"; 6 - import { normalize } from "./shared/utils/string.utils"; 1 + import { AuthenticatedHandler } from "./core/types"; 2 + import { SessionService } from "./services/SessionService"; 3 + import { successResponse } from "./utils"; 4 + import { withAuthErrorHandling } from "./core/middleware"; 5 + import { ValidationError } from "./core/errors"; 6 + import { normalize } from "./utils/string.utils"; 7 + import { FollowService } from "./services/FollowService"; 7 8 8 9 const batchSearchHandler: AuthenticatedHandler = async (context) => { 9 - // Parse batch request 10 10 const body = JSON.parse(context.event.body || "{}"); 11 11 const usernames: string[] = body.usernames || []; 12 12 ··· 16 16 ); 17 17 } 18 18 19 - // Limit batch size to prevent timeouts 20 19 if (usernames.length > 50) { 21 20 throw new ValidationError("Maximum 50 usernames per batch"); 22 21 } 23 22 24 - // Get authenticated agent using SessionService 25 - const { agent } = await SessionService.getAgentForSession(context.sessionId); 23 + const { agent } = await SessionService.getAgentForSession( 24 + context.sessionId, 25 + context.event, 26 + ); 26 27 27 - // Search all usernames in parallel 28 28 const searchPromises = usernames.map(async (username) => { 29 29 try { 30 30 const response = await agent.app.bsky.actor.searchActors({ ··· 32 32 limit: 20, 33 33 }); 34 34 35 - // Filter and rank matches 36 35 const normalizedUsername = normalize(username); 37 36 38 37 const rankedActors = response.data.actors ··· 79 78 80 79 const results = await Promise.all(searchPromises); 81 80 82 - // Enrich results with follower and post counts using getProfiles 83 81 const allDids = results 84 82 .flatMap((r) => r.actors.map((a: any) => a.did)) 85 83 .filter((did): did is string => !!did); 86 84 87 85 if (allDids.length > 0) { 88 - // Create a map to store enriched profile data 89 86 const profileDataMap = new Map< 90 87 string, 91 88 { postCount: number; followerCount: number } 92 89 >(); 93 90 94 - // Batch fetch profiles (25 at a time - API limit) 95 91 const PROFILE_BATCH_SIZE = 25; 96 92 for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) { 97 93 const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE); ··· 108 104 }); 109 105 } catch (error) { 110 106 console.error("Failed to fetch profile batch:", error); 111 - // Continue even if one batch fails 112 107 } 113 108 } 114 109 115 - // Merge enriched data back into results 116 110 results.forEach((result) => { 117 111 result.actors = result.actors.map((actor: any) => { 118 112 const enrichedData = profileDataMap.get(actor.did); ··· 125 119 }); 126 120 } 127 121 128 - // Check follow status for all matched DIDs in chosen lexicon 129 122 const followLexicon = body.followLexicon || "app.bsky.graph.follow"; 130 123 131 124 if (allDids.length > 0) { 132 125 try { 133 - let cursor: string | undefined = undefined; 134 - let hasMore = true; 135 - const didsSet = new Set(allDids); 136 - const followedDids = new Set<string>(); 137 - 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, 145 - }); 146 - 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); 152 - } 153 - } 154 - 155 - cursor = response.data.cursor; 156 - hasMore = !!cursor; 157 - } 126 + const followStatus = await FollowService.checkFollowStatus( 127 + agent, 128 + context.did, 129 + allDids, 130 + followLexicon, 131 + ); 158 132 159 - // Add follow status to results 160 133 results.forEach((result) => { 161 134 result.actors = result.actors.map((actor: any) => ({ 162 135 ...actor, 163 136 followStatus: { 164 - [followLexicon]: followedDids.has(actor.did), 137 + [followLexicon]: followStatus[actor.did] || false, 165 138 }, 166 139 })); 167 140 }); 168 141 } catch (error) { 169 142 console.error("Failed to check follow status during search:", error); 170 - // Continue without follow status - non-critical 171 143 } 172 144 } 173 145
+16 -52
netlify/functions/check-follow-status.ts
··· 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"; 1 + import { AuthenticatedHandler } from "./core/types"; 2 + import { SessionService } from "./services/SessionService"; 3 + import { FollowService } from "./services/FollowService"; 4 + import { successResponse } from "./utils"; 5 + import { withAuthErrorHandling } from "./core/middleware"; 6 + import { ValidationError } from "./core/errors"; 6 7 7 8 const checkFollowStatusHandler: AuthenticatedHandler = async (context) => { 8 - // Parse request body 9 9 const body = JSON.parse(context.event.body || "{}"); 10 10 const dids: string[] = body.dids || []; 11 11 const followLexicon: string = body.followLexicon || "app.bsky.graph.follow"; ··· 14 14 throw new ValidationError("dids array is required and must not be empty"); 15 15 } 16 16 17 - // Limit batch size 18 17 if (dids.length > 100) { 19 18 throw new ValidationError("Maximum 100 DIDs per batch"); 20 19 } 21 20 22 - // Get authenticated agent using SessionService 23 - const { agent } = await SessionService.getAgentForSession(context.sessionId); 24 - 25 - // Build follow status map 26 - const followStatus: Record<string, boolean> = {}; 27 - 28 - // Initialize all as not following 29 - dids.forEach((did) => { 30 - followStatus[did] = false; 31 - }); 32 - 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); 38 - 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 - }); 46 - 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 53 - } 54 - } 55 - 56 - cursor = response.data.cursor; 57 - hasMore = !!cursor; 21 + const { agent } = await SessionService.getAgentForSession( 22 + context.sessionId, 23 + context.event, 24 + ); 58 25 59 - // If we've found all DIDs, break early 60 - if (didsSet.size === 0) { 61 - break; 62 - } 63 - } 64 - } catch (error) { 65 - console.error("Error querying follow graph:", error); 66 - // On error, return all as false (not following) - fail safe 67 - } 26 + const followStatus = await FollowService.checkFollowStatus( 27 + agent, 28 + context.did, 29 + dids, 30 + followLexicon, 31 + ); 68 32 69 33 return successResponse({ followStatus }); 70 34 };
+11
netlify/functions/core/errors/ApiError.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 + Object.setPrototypeOf(this, ApiError.prototype); 10 + } 11 + }
+9
netlify/functions/core/errors/AuthenticationError.ts
··· 1 + import { ApiError } from "./ApiError"; 2 + 3 + export class AuthenticationError extends ApiError { 4 + constructor(message: string = "Authentication required", details?: string) { 5 + super(message, 401, details); 6 + this.name = "AuthenticationError"; 7 + Object.setPrototypeOf(this, AuthenticationError.prototype); 8 + } 9 + }
+9
netlify/functions/core/errors/DatabaseError.ts
··· 1 + import { ApiError } from "./ApiError"; 2 + 3 + export class DatabaseError extends ApiError { 4 + constructor(message: string = "Database operation failed", details?: string) { 5 + super(message, 500, details); 6 + this.name = "DatabaseError"; 7 + Object.setPrototypeOf(this, DatabaseError.prototype); 8 + } 9 + }
+9
netlify/functions/core/errors/NotFoundError.ts
··· 1 + import { ApiError } from "./ApiError"; 2 + 3 + export class NotFoundError extends ApiError { 4 + constructor(message: string = "Resource not found", details?: string) { 5 + super(message, 404, details); 6 + this.name = "NotFoundError"; 7 + Object.setPrototypeOf(this, NotFoundError.prototype); 8 + } 9 + }
+9
netlify/functions/core/errors/ValidationError.ts
··· 1 + import { ApiError } from "./ApiError"; 2 + 3 + export class ValidationError extends ApiError { 4 + constructor(message: string, details?: string) { 5 + super(message, 400, details); 6 + this.name = "ValidationError"; 7 + Object.setPrototypeOf(this, ValidationError.prototype); 8 + } 9 + }
+17
netlify/functions/core/errors/index.ts
··· 1 + export * from "./ApiError"; 2 + export * from "./AuthenticationError"; 3 + export * from "./ValidationError"; 4 + export * from "./NotFoundError"; 5 + export * from "./DatabaseError"; 6 + 7 + export const ERROR_MESSAGES = { 8 + NO_SESSION_COOKIE: "No session cookie", 9 + INVALID_SESSION: "Invalid or expired session", 10 + MISSING_PARAMETERS: "Missing required parameters", 11 + OAUTH_FAILED: "OAuth authentication failed", 12 + DATABASE_ERROR: "Database operation failed", 13 + PROFILE_FETCH_FAILED: "Failed to fetch profile", 14 + SEARCH_FAILED: "Search operation failed", 15 + FOLLOW_FAILED: "Follow operation failed", 16 + SAVE_FAILED: "Failed to save results", 17 + } as const;
+5 -5
netlify/functions/get-upload-details.ts
··· 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"; 1 + import { AuthenticatedHandler } from "./core/types"; 2 + import { MatchRepository } from "./repositories"; 3 + import { successResponse } from "./utils"; 4 + import { withAuthErrorHandling } from "./core/middleware"; 5 + import { ValidationError, NotFoundError } from "./core/errors"; 6 6 7 7 const DEFAULT_PAGE_SIZE = 50; 8 8 const MAX_PAGE_SIZE = 100;
+4 -5
netlify/functions/get-uploads.ts
··· 1 - import { AuthenticatedHandler } from "./shared/types"; 2 - import { UploadRepository } from "./shared/repositories"; 3 - import { successResponse } from "./shared/utils"; 4 - import { withAuthErrorHandling } from "./shared/middleware"; 1 + import { AuthenticatedHandler } from "./core/types"; 2 + import { UploadRepository } from "./repositories"; 3 + import { successResponse } from "./utils"; 4 + import { withAuthErrorHandling } from "./core/middleware"; 5 5 6 6 const getUploadsHandler: AuthenticatedHandler = async (context) => { 7 7 const uploadRepo = new UploadRepository(); 8 8 9 - // Fetch all uploads for this user 10 9 const uploads = await uploadRepo.getUserUploads(context.did); 11 10 12 11 return successResponse({
+75
netlify/functions/infrastructure/cache/CacheService.ts
··· 1 + /** 2 + * Generic in-memory cache with TTL support 3 + * Used for both server-side caching (config, profiles) and client-side caching 4 + **/ 5 + export class CacheService<T = any> { 6 + private cache = new Map<string, { value: T; expires: number }>(); 7 + private readonly defaultTTL: number; 8 + 9 + constructor(defaultTTLMs: number = 5 * 60 * 1000) { 10 + this.defaultTTL = defaultTTLMs; 11 + } 12 + 13 + set(key: string, value: T, ttlMs?: number): void { 14 + const ttl = ttlMs ?? this.defaultTTL; 15 + this.cache.set(key, { 16 + value, 17 + expires: Date.now() + ttl, 18 + }); 19 + 20 + // Auto-cleanup if cache grows too large 21 + if (this.cache.size > 100) { 22 + this.cleanup(); 23 + } 24 + } 25 + 26 + get(key: string, ttlMs?: number): T | null { 27 + const entry = this.cache.get(key); 28 + if (!entry) return null; 29 + 30 + const ttl = ttlMs ?? this.defaultTTL; 31 + if (Date.now() - (entry.expires - ttl) > ttl) { 32 + this.cache.delete(key); 33 + return null; 34 + } 35 + 36 + return entry.value; 37 + } 38 + 39 + has(key: string): boolean { 40 + return this.get(key) !== null; 41 + } 42 + 43 + delete(key: string): void { 44 + this.cache.delete(key); 45 + } 46 + 47 + invalidatePattern(pattern: string): void { 48 + for (const key of this.cache.keys()) { 49 + if (key.includes(pattern)) { 50 + this.cache.delete(key); 51 + } 52 + } 53 + } 54 + 55 + clear(): void { 56 + this.cache.clear(); 57 + } 58 + 59 + cleanup(): void { 60 + const now = Date.now(); 61 + for (const [key, entry] of this.cache.entries()) { 62 + if (now > entry.expires) { 63 + this.cache.delete(key); 64 + } 65 + } 66 + } 67 + 68 + size(): number { 69 + return this.cache.size; 70 + } 71 + } 72 + 73 + // Singleton instances for common use cases 74 + export const configCache = new CacheService<any>(5 * 60 * 1000); // 5 min 75 + export const profileCache = new CacheService<any>(5 * 60 * 1000); // 5 min
+70
netlify/functions/infrastructure/database/DatabaseConnection.ts
··· 1 + import { neon, NeonQueryFunction } from "@neondatabase/serverless"; 2 + import { DatabaseError } from "../../core/errors"; 3 + 4 + /** 5 + * Singleton Database Connection Manager 6 + * Ensures single connection instance across all function invocations 7 + **/ 8 + class DatabaseConnection { 9 + private static instance: DatabaseConnection; 10 + private sql: NeonQueryFunction<any, any> | null = null; 11 + private initialized = false; 12 + 13 + private constructor() {} 14 + 15 + static getInstance(): DatabaseConnection { 16 + if (!DatabaseConnection.instance) { 17 + DatabaseConnection.instance = new DatabaseConnection(); 18 + } 19 + return DatabaseConnection.instance; 20 + } 21 + 22 + getClient(): NeonQueryFunction<any, any> { 23 + if (!this.sql) { 24 + this.initialize(); 25 + } 26 + return this.sql!; 27 + } 28 + 29 + private initialize(): void { 30 + if (this.initialized) return; 31 + 32 + if (!process.env.NETLIFY_DATABASE_URL) { 33 + throw new DatabaseError( 34 + "Database connection string not configured", 35 + "NETLIFY_DATABASE_URL environment variable is missing", 36 + ); 37 + } 38 + 39 + try { 40 + this.sql = neon(process.env.NETLIFY_DATABASE_URL); 41 + this.initialized = true; 42 + 43 + if (process.env.NODE_ENV !== "production") { 44 + console.log("✅ Database connection initialized"); 45 + } 46 + } catch (error) { 47 + throw new DatabaseError( 48 + "Failed to initialize database connection", 49 + error instanceof Error ? error.message : "Unknown error", 50 + ); 51 + } 52 + } 53 + 54 + isInitialized(): boolean { 55 + return this.initialized; 56 + } 57 + 58 + // For testing purposes only 59 + reset(): void { 60 + this.sql = null; 61 + this.initialized = false; 62 + } 63 + } 64 + 65 + // Export singleton instance methods 66 + const dbConnection = DatabaseConnection.getInstance(); 67 + 68 + export const getDbClient = () => dbConnection.getClient(); 69 + export const isConnectionInitialized = () => dbConnection.isInitialized(); 70 + export const resetConnection = () => dbConnection.reset();
+2
netlify/functions/infrastructure/database/index.ts
··· 1 + export * from "./DatabaseConnection"; 2 + export * from "./DatabaseService";
+2
netlify/functions/infrastructure/oauth/index.ts
··· 1 + export * from "./config"; 2 + export * from "./OAuthClientFactory";
+4 -4
netlify/functions/init-db.ts
··· 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"; 1 + import { SimpleHandler } from "./core/types/api.types"; 2 + import { DatabaseService } from "./infrastructure/database/DatabaseService"; 3 + import { withErrorHandling } from "./core/middleware"; 4 + import { successResponse } from "./utils"; 5 5 6 6 const initDbHandler: SimpleHandler = async () => { 7 7 const dbService = new DatabaseService();
+11 -10
netlify/functions/logout.ts
··· 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"; 1 + import { ApiError } from "./core/errors"; 2 + import { SimpleHandler } from "./core/types/api.types"; 3 + import { SessionService } from "./services/SessionService"; 4 + import { getOAuthConfig } from "./infrastructure/oauth"; 5 + import { extractSessionId } from "./core/middleware"; 6 + import { withErrorHandling } from "./core/middleware"; 6 7 7 8 const logoutHandler: SimpleHandler = async (event) => { 8 - // Only allow POST for logout 9 9 if (event.httpMethod !== "POST") { 10 - throw new Error("Method not allowed"); 10 + throw new ApiError( 11 + "Method not allowed", 12 + 405, 13 + `Only POST method is supported for ${event.path}`, 14 + ); 11 15 } 12 16 13 17 console.log("[logout] Starting logout process..."); 14 - console.log("[logout] Cookies received:", event.headers.cookie); 15 18 16 19 const sessionId = extractSessionId(event); 17 20 console.log("[logout] Session ID from cookie:", sessionId); 18 21 19 22 if (sessionId) { 20 - // Use SessionService to properly clean up both user and OAuth sessions 21 23 await SessionService.deleteSession(sessionId); 22 24 console.log("[logout] Successfully deleted session:", sessionId); 23 25 } 24 26 25 - // Clear the session cookie with matching flags from when it was set 26 27 const config = getOAuthConfig(); 27 28 const isDev = config.clientType === "loopback"; 28 29
+6 -10
netlify/functions/oauth-callback.ts
··· 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"; 1 + import { SimpleHandler } from "./core/types/api.types"; 2 + import { createOAuthClient, getOAuthConfig } from "./infrastructure/oauth"; 3 + import { userSessions } from "./infrastructure/oauth/stores"; 4 + import { redirectResponse } from "./utils"; 5 + import { withErrorHandling } from "./core/middleware"; 6 + import { CONFIG } from "./core/config/constants"; 7 7 import * as crypto from "crypto"; 8 8 9 9 const oauthCallbackHandler: SimpleHandler = async (event) => { ··· 28 28 return redirectResponse(`${currentUrl}/?error=Missing OAuth parameters`); 29 29 } 30 30 31 - // Create OAuth client using shared helper 32 31 const client = await createOAuthClient(); 33 32 34 - // Process the OAuth callback 35 33 const result = await client.callback(params); 36 34 37 35 console.log( ··· 39 37 result.session.did, 40 38 ); 41 39 42 - // Store session 43 40 const sessionId = crypto.randomUUID(); 44 41 const did = result.session.did; 45 42 await userSessions.set(sessionId, { did }); 46 43 47 44 console.log("[oauth-callback] Created user session:", sessionId); 48 45 49 - // Cookie flags - no Secure flag for loopback 50 46 const cookieFlags = isDev 51 47 ? `HttpOnly; SameSite=Lax; Max-Age=${CONFIG.COOKIE_MAX_AGE}; Path=/` 52 48 : `HttpOnly; SameSite=Lax; Max-Age=${CONFIG.COOKIE_MAX_AGE}; Path=/; Secure`;
+5 -7
netlify/functions/oauth-start.ts
··· 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"; 1 + import { SimpleHandler } from "./core/types/api.types"; 2 + import { createOAuthClient } from "./infrastructure/oauth/OAuthClientFactory"; 3 + import { successResponse } from "./utils"; 4 + import { withErrorHandling } from "./core/middleware"; 5 + import { ValidationError } from "./core/errors"; 6 6 7 7 interface OAuthStartRequestBody { 8 8 login_hint?: string; ··· 23 23 24 24 console.log("[oauth-start] Starting OAuth flow for:", loginHint); 25 25 26 - // Create OAuth client using shared helper 27 26 const client = await createOAuthClient(event); 28 27 29 - // Start the authorization flow 30 28 const authUrl = await client.authorize(loginHint, { 31 29 scope: "atproto transition:generic", 32 30 });
+46
netlify/functions/repositories/BaseRepository.ts
··· 1 + import { getDbClient } from "../infrastructure/database/DatabaseConnection"; 2 + import { NeonQueryFunction } from "@neondatabase/serverless"; 3 + 4 + export abstract class BaseRepository { 5 + protected sql: NeonQueryFunction<any, any>; 6 + 7 + constructor() { 8 + this.sql = getDbClient(); 9 + } 10 + 11 + /** 12 + * Execute a raw query 13 + */ 14 + protected async query<T>( 15 + queryFn: (sql: NeonQueryFunction<any, any>) => Promise<T>, 16 + ): Promise<T> { 17 + return await queryFn(this.sql); 18 + } 19 + 20 + /** 21 + * Helper: Build UNNEST arrays for bulk operations 22 + * Returns arrays organized by column for UNNEST pattern 23 + */ 24 + protected buildUnnestArrays<T extends any[]>( 25 + columns: string[], 26 + rows: T[], 27 + ): any[][] { 28 + return columns.map((_, colIndex) => rows.map((row) => row[colIndex])); 29 + } 30 + 31 + /** 32 + * Helper: Extract results into a Map 33 + * Common pattern for bulk operations that return id mappings 34 + */ 35 + protected buildIdMap<T extends Record<string, any>>( 36 + results: T[], 37 + keyField: string, 38 + valueField: string = "id", 39 + ): Map<string, number> { 40 + const map = new Map<string, number>(); 41 + for (const row of results) { 42 + map.set(row[keyField], row[valueField]); 43 + } 44 + return map; 45 + } 46 + }
+6 -17
netlify/functions/save-results.ts
··· 1 - import { AuthenticatedHandler } from "./shared/types"; 1 + import { AuthenticatedHandler } from "./core/types"; 2 2 import { 3 3 UploadRepository, 4 4 SourceAccountRepository, 5 5 MatchRepository, 6 - } from "./shared/repositories"; 7 - import { successResponse } from "./shared/utils"; 8 - import { normalize } from "./shared/utils"; 9 - import { withAuthErrorHandling } from "./shared/middleware"; 10 - import { ValidationError } from "./shared/constants/errors"; 6 + } from "./repositories"; 7 + import { successResponse } from "./utils"; 8 + import { normalize } from "./utils/string.utils"; 9 + import { withAuthErrorHandling } from "./core/middleware"; 10 + import { ValidationError } from "./core/errors"; 11 11 12 12 interface SearchResult { 13 13 sourceUser: { ··· 37 37 } 38 38 39 39 const saveResultsHandler: AuthenticatedHandler = async (context) => { 40 - // Parse request body 41 40 const body: SaveResultsRequest = JSON.parse(context.event.body || "{}"); 42 41 const { uploadId, sourcePlatform, results, saveData } = body; 43 42 ··· 47 46 ); 48 47 } 49 48 50 - // Server-side validation for saveData flag, controlled by frontend 51 49 if (saveData === false) { 52 50 console.log( 53 51 `User ${context.did} has data storage disabled - skipping save`, ··· 68 66 const matchRepo = new MatchRepository(); 69 67 let matchedCount = 0; 70 68 71 - // Check for recent uploads from this user 72 69 const hasRecent = await uploadRepo.hasRecentUpload(context.did); 73 70 if (hasRecent) { 74 71 console.log( ··· 80 77 }); 81 78 } 82 79 83 - // Create upload record FIRST 84 80 await uploadRepo.createUpload( 85 81 uploadId, 86 82 context.did, ··· 89 85 0, 90 86 ); 91 87 92 - // BULK OPERATION 1: Create all source accounts at once 93 88 const allUsernames = results.map((r) => r.sourceUser.username); 94 89 const sourceAccountIdMap = await sourceAccountRepo.bulkCreate( 95 90 sourcePlatform, 96 91 allUsernames, 97 92 ); 98 93 99 - // BULK OPERATION 2: Link all users to source accounts 100 94 const links = results 101 95 .map((result) => { 102 96 const normalized = normalize(result.sourceUser.username); ··· 110 104 111 105 await sourceAccountRepo.linkUserToAccounts(uploadId, context.did, links); 112 106 113 - // BULK OPERATION 3: Store all atproto matches at once 114 107 const allMatches: Array<{ 115 108 sourceAccountId: number; 116 109 atprotoDid: string; ··· 153 146 } 154 147 } 155 148 156 - // Store all matches in one operation 157 149 let matchIdMap = new Map<string, number>(); 158 150 if (allMatches.length > 0) { 159 151 matchIdMap = await matchRepo.bulkStoreMatches(allMatches); 160 152 } 161 153 162 - // BULK OPERATION 4: Mark all matched source accounts 163 154 if (matchedSourceAccountIds.length > 0) { 164 155 await sourceAccountRepo.markAsMatched(matchedSourceAccountIds); 165 156 } 166 157 167 - // BULK OPERATION 5: Create all user match statuses 168 158 const statuses: Array<{ 169 159 did: string; 170 160 atprotoMatchId: number; ··· 189 179 await matchRepo.upsertUserMatchStatus(statuses); 190 180 } 191 181 192 - // Update upload record with final counts 193 182 await uploadRepo.updateMatchCounts( 194 183 uploadId, 195 184 matchedCount,
+93
netlify/functions/services/FollowService.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + 3 + interface FollowStatusResult { 4 + [did: string]: boolean; 5 + } 6 + 7 + /** 8 + * Centralized Follow Service 9 + * Handles all follow-related operations to eliminate duplication 10 + **/ 11 + export class FollowService { 12 + /** 13 + * Check follow status for multiple DIDs 14 + * Returns a map of DID -> isFollowing 15 + **/ 16 + static async checkFollowStatus( 17 + agent: Agent, 18 + userDid: string, 19 + dids: string[], 20 + followLexicon: string = "app.bsky.graph.follow", 21 + ): Promise<FollowStatusResult> { 22 + const followStatus: FollowStatusResult = {}; 23 + 24 + // Initialize all as not following 25 + dids.forEach((did) => { 26 + followStatus[did] = false; 27 + }); 28 + 29 + if (dids.length === 0) { 30 + return followStatus; 31 + } 32 + 33 + try { 34 + let cursor: string | undefined = undefined; 35 + let hasMore = true; 36 + const didsSet = new Set(dids); 37 + 38 + while (hasMore && didsSet.size > 0) { 39 + const response = await agent.api.com.atproto.repo.listRecords({ 40 + repo: userDid, 41 + collection: followLexicon, 42 + limit: 100, 43 + cursor, 44 + }); 45 + 46 + // Check each record 47 + for (const record of response.data.records) { 48 + const followRecord = record.value as any; 49 + if (followRecord?.subject && didsSet.has(followRecord.subject)) { 50 + followStatus[followRecord.subject] = true; 51 + didsSet.delete(followRecord.subject); // Found it, no need to keep checking 52 + } 53 + } 54 + 55 + cursor = response.data.cursor; 56 + hasMore = !!cursor; 57 + 58 + // If we've found all DIDs, break early 59 + if (didsSet.size === 0) { 60 + break; 61 + } 62 + } 63 + } catch (error) { 64 + console.error("Error checking follow status:", error); 65 + // Return all as false on error (fail-safe) 66 + } 67 + 68 + return followStatus; 69 + } 70 + 71 + /** 72 + * Get list of already followed DIDs from a set 73 + **/ 74 + static async getAlreadyFollowing( 75 + agent: Agent, 76 + userDid: string, 77 + dids: string[], 78 + followLexicon: string = "app.bsky.graph.follow", 79 + ): Promise<Set<string>> { 80 + const followStatus = await this.checkFollowStatus( 81 + agent, 82 + userDid, 83 + dids, 84 + followLexicon, 85 + ); 86 + 87 + return new Set( 88 + Object.entries(followStatus) 89 + .filter(([_, isFollowing]) => isFollowing) 90 + .map(([did]) => did), 91 + ); 92 + } 93 + }
+84
netlify/functions/services/SessionService.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 + import type { HandlerEvent } from "@netlify/functions"; 4 + import { AuthenticationError, ERROR_MESSAGES } from "../core/errors"; 5 + import { createOAuthClient } from "../infrastructure/oauth"; 6 + import { userSessions } from "../infrastructure/oauth/stores"; 7 + import { configCache } from "../infrastructure/cache/CacheService"; 8 + 9 + export class SessionService { 10 + static async getAgentForSession( 11 + sessionId: string, 12 + event?: HandlerEvent, 13 + ): Promise<{ 14 + agent: Agent; 15 + did: string; 16 + client: NodeOAuthClient; 17 + }> { 18 + console.log("[SessionService] Getting agent for session:", sessionId); 19 + 20 + const userSession = await userSessions.get(sessionId); 21 + if (!userSession) { 22 + throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 23 + } 24 + 25 + const did = userSession.did; 26 + console.log("[SessionService] Found user session for DID:", did); 27 + 28 + // Cache the OAuth client per session for 5 minutes 29 + const cacheKey = `oauth-client-${sessionId}`; 30 + let client = configCache.get(cacheKey) as NodeOAuthClient | null; 31 + 32 + if (!client) { 33 + client = await createOAuthClient(event); 34 + configCache.set(cacheKey, client, 5 * 60 * 1000); // 5 minutes 35 + console.log("[SessionService] Created and cached OAuth client"); 36 + } else { 37 + console.log("[SessionService] Using cached OAuth client"); 38 + } 39 + 40 + const oauthSession = await client.restore(did); 41 + console.log("[SessionService] Restored OAuth session for DID:", did); 42 + 43 + const agent = new Agent(oauthSession); 44 + 45 + return { agent, did, client }; 46 + } 47 + 48 + static async deleteSession(sessionId: string): Promise<void> { 49 + console.log("[SessionService] Deleting session:", sessionId); 50 + 51 + const userSession = await userSessions.get(sessionId); 52 + if (!userSession) { 53 + console.log("[SessionService] Session not found:", sessionId); 54 + return; 55 + } 56 + 57 + const did = userSession.did; 58 + 59 + try { 60 + const client = await createOAuthClient(); 61 + await client.revoke(did); 62 + console.log("[SessionService] Revoked OAuth session for DID:", did); 63 + } catch (error) { 64 + console.log("[SessionService] Could not revoke OAuth session:", error); 65 + } 66 + 67 + await userSessions.del(sessionId); 68 + 69 + // Clear cached OAuth client 70 + configCache.delete(`oauth-client-${sessionId}`); 71 + 72 + console.log("[SessionService] Deleted user session:", sessionId); 73 + } 74 + 75 + static async verifySession(sessionId: string): Promise<boolean> { 76 + const userSession = await userSessions.get(sessionId); 77 + return userSession !== undefined; 78 + } 79 + 80 + static async getDIDForSession(sessionId: string): Promise<string | null> { 81 + const userSession = await userSessions.get(sessionId); 82 + return userSession?.did || null; 83 + } 84 + }
+12 -35
netlify/functions/session.ts
··· 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"; 7 - 8 - // In-memory cache for profile 9 - const profileCache = new Map<string, { data: any; timestamp: number }>(); 10 - const PROFILE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 1 + import { SimpleHandler } from "./core/types/api.types"; 2 + import { SessionService } from "./services/SessionService"; 3 + import { extractSessionId } from "./core/middleware"; 4 + import { successResponse } from "./utils"; 5 + import { withErrorHandling } from "./core/middleware"; 6 + import { AuthenticationError, ERROR_MESSAGES } from "./core/errors"; 7 + import { profileCache } from "./infrastructure/cache/CacheService"; 11 8 12 9 const sessionHandler: SimpleHandler = async (event) => { 13 10 const sessionId = ··· 17 14 throw new AuthenticationError(ERROR_MESSAGES.NO_SESSION_COOKIE); 18 15 } 19 16 20 - // Verify session exists 21 17 const isValid = await SessionService.verifySession(sessionId); 22 18 if (!isValid) { 23 19 throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 24 20 } 25 21 26 - // Get DID from session 27 22 const did = await SessionService.getDIDForSession(sessionId); 28 23 if (!did) { 29 24 throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 30 25 } 31 26 32 - const now = Date.now(); 33 - 34 - // Check profile cache 35 - const cached = profileCache.get(did); 36 - if (cached && now - cached.timestamp < PROFILE_CACHE_TTL) { 27 + const cached = profileCache.get<any>(did); 28 + if (cached) { 37 29 console.log("Returning cached profile for", did); 38 - return successResponse(cached.data, 200, { 30 + return successResponse(cached, 200, { 39 31 "Cache-Control": "private, max-age=300", 40 32 "X-Cache-Status": "HIT", 41 33 }); 42 34 } 43 35 44 - // Cache miss - fetch full profile 45 - const { agent } = await SessionService.getAgentForSession(sessionId); 36 + const { agent } = await SessionService.getAgentForSession(sessionId, event); 46 37 47 - // Get profile - throw error if this fails 48 38 const profile = await agent.getProfile({ actor: did }); 49 39 50 40 const profileData = { ··· 55 45 description: profile.data.description, 56 46 }; 57 47 58 - // Cache the profile data 59 - profileCache.set(did, { 60 - data: profileData, 61 - timestamp: now, 62 - }); 63 - 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); 69 - } 70 - } 71 - } 48 + profileCache.set(did, profileData); 72 49 73 50 return successResponse(profileData, 200, { 74 51 "Cache-Control": "private, max-age=300",
-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;
-2
netlify/functions/shared/constants/index.ts netlify/functions/core/config/constants.ts
··· 1 - export * from "./errors"; 2 - 3 1 export const CONFIG = { 4 2 PROFILE_CACHE_TTL: 5 * 60 * 1000, // 5 minutes 5 3 SESSION_EXPIRY: 30 * 24 * 60 * 60 * 1000, // 30 days
-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";
+2 -2
netlify/functions/shared/middleware/auth.middleware.ts netlify/functions/core/middleware/auth.middleware.ts
··· 1 1 import { HandlerEvent } from "@netlify/functions"; 2 2 import cookie from "cookie"; 3 - import { userSessions } from "../services/session/stores"; 4 - import { AuthenticationError, ERROR_MESSAGES } from "../constants/errors"; 3 + import { userSessions } from "../../infrastructure/oauth/stores"; 4 + import { AuthenticationError, ERROR_MESSAGES } from "../errors"; 5 5 import { AuthenticatedContext } from "../types"; 6 6 7 7 /**
+2 -2
netlify/functions/shared/middleware/error.middleware.ts netlify/functions/core/middleware/error.middleware.ts
··· 1 1 import { HandlerEvent, HandlerResponse, Handler } from "@netlify/functions"; 2 - import { ApiError } from "../constants/errors"; 3 - import { errorResponse } from "../utils/response.utils"; 2 + import { ApiError } from "../errors"; 3 + import { errorResponse } from "../../utils/"; 4 4 import { SimpleHandler, AuthenticatedHandler } from "../types"; 5 5 6 6 /**
netlify/functions/shared/middleware/index.ts netlify/functions/core/middleware/index.ts
-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 - }
+63 -47
netlify/functions/shared/repositories/MatchRepository.ts netlify/functions/repositories/MatchRepository.ts
··· 1 1 import { BaseRepository } from "./BaseRepository"; 2 - import { AtprotoMatchRow } from "../types"; 3 2 4 3 export class MatchRepository extends BaseRepository { 5 - /** 6 - * Store a single atproto match 7 - **/ 8 4 async storeMatch( 9 5 sourceAccountId: number, 10 6 atprotoDid: string, ··· 42 38 return (result as any[])[0].id; 43 39 } 44 40 45 - /** 46 - * Bulk store atproto matches 47 - **/ 48 41 async bulkStoreMatches( 49 42 matches: Array<{ 50 43 sourceAccountId: number; ··· 61 54 ): Promise<Map<string, number>> { 62 55 if (matches.length === 0) return new Map(); 63 56 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) => 57 + const rows = matches.map((m) => [ 58 + m.sourceAccountId, 59 + m.atprotoDid, 60 + m.atprotoHandle, 61 + m.atprotoDisplayName || null, 62 + m.atprotoAvatar || null, 63 + m.atprotoDescription || null, 64 + m.matchScore, 65 + m.postCount || 0, 66 + m.followerCount || 0, 74 67 JSON.stringify(m.followStatus || {}), 68 + ]); 69 + 70 + const [ 71 + sourceAccountIds, 72 + atprotoDids, 73 + atprotoHandles, 74 + atprotoDisplayNames, 75 + atprotoAvatars, 76 + atprotoDescriptions, 77 + matchScores, 78 + postCounts, 79 + followerCounts, 80 + followStatuses, 81 + ] = this.buildUnnestArrays( 82 + [ 83 + "source_account_id", 84 + "atproto_did", 85 + "atproto_handle", 86 + "atproto_display_name", 87 + "atproto_avatar", 88 + "atproto_description", 89 + "match_score", 90 + "post_count", 91 + "follower_count", 92 + "follow_status", 93 + ], 94 + rows, 75 95 ); 76 96 77 97 const result = await this.sql` ··· 81 101 match_score, post_count, follower_count, follow_status 82 102 ) 83 103 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[] 104 + ${sourceAccountIds}::integer[], 105 + ${atprotoDids}::text[], 106 + ${atprotoHandles}::text[], 107 + ${atprotoDisplayNames}::text[], 108 + ${atprotoAvatars}::text[], 109 + ${atprotoDescriptions}::text[], 110 + ${matchScores}::integer[], 111 + ${postCounts}::integer[], 112 + ${followerCounts}::integer[], 113 + ${followStatuses}::jsonb[] 94 114 ) AS t( 95 115 source_account_id, atproto_did, atproto_handle, 96 116 atproto_display_name, atproto_avatar, atproto_description, ··· 109 129 RETURNING id, source_account_id, atproto_did 110 130 `; 111 131 112 - // Create map of "sourceAccountId:atprotoDid" to match ID 113 132 const idMap = new Map<string, number>(); 114 133 for (const row of result as any[]) { 115 134 idMap.set(`${row.source_account_id}:${row.atproto_did}`, row.id); ··· 118 137 return idMap; 119 138 } 120 139 121 - /** 122 - * Get upload details with pagination 123 - **/ 124 140 async getUploadDetails( 125 141 uploadId: string, 126 142 did: string, ··· 130 146 results: any[]; 131 147 totalUsers: number; 132 148 }> { 133 - // First verify upload belongs to user and get total count 134 149 const uploadCheck = await this.sql` 135 150 SELECT upload_id, total_users FROM user_uploads 136 151 WHERE upload_id = ${uploadId} AND did = ${did} ··· 143 158 const totalUsers = (uploadCheck as any[])[0].total_users; 144 159 const offset = (page - 1) * pageSize; 145 160 146 - // Fetch paginated results 147 161 const results = await this.sql` 148 162 SELECT 149 163 sa.source_username, ··· 185 199 }; 186 200 } 187 201 188 - /** 189 - * Update follow status for a match 190 - **/ 191 202 async updateFollowStatus( 192 203 atprotoDid: string, 193 204 followLexicon: string, ··· 201 212 `; 202 213 } 203 214 204 - /** 205 - * Create or update user match status 206 - **/ 207 215 async upsertUserMatchStatus( 208 216 statuses: Array<{ 209 217 did: string; ··· 214 222 ): Promise<void> { 215 223 if (statuses.length === 0) return; 216 224 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)); 225 + const rows = statuses.map((s) => [ 226 + s.did, 227 + s.atprotoMatchId, 228 + s.sourceAccountId, 229 + s.viewed, 230 + s.viewed ? new Date().toISOString() : null, 231 + ]); 232 + 233 + const [dids, atprotoMatchIds, sourceAccountIds, viewedFlags, viewedDates] = 234 + this.buildUnnestArrays( 235 + ["did", "atproto_match_id", "source_account_id", "viewed", "viewed_at"], 236 + rows, 237 + ); 222 238 223 239 await this.sql` 224 240 INSERT INTO user_match_status (did, atproto_match_id, source_account_id, viewed, viewed_at) 225 241 SELECT * FROM UNNEST( 226 - ${did}::text[], 227 - ${atprotoMatchId}::integer[], 228 - ${sourceAccountId}::integer[], 242 + ${dids}::text[], 243 + ${atprotoMatchIds}::integer[], 244 + ${sourceAccountIds}::integer[], 229 245 ${viewedFlags}::boolean[], 230 246 ${viewedDates}::timestamp[] 231 247 ) AS t(did, atproto_match_id, source_account_id, viewed, viewed_at)
+30 -37
netlify/functions/shared/repositories/SourceAccountRepository.ts netlify/functions/repositories/SourceAccountRepository.ts
··· 1 1 import { BaseRepository } from "./BaseRepository"; 2 - import { normalize } from "../utils"; 2 + import { normalize } from "../utils/string.utils"; 3 3 4 4 export class SourceAccountRepository extends BaseRepository { 5 - /** 6 - * Get or create a source account 7 - **/ 8 5 async getOrCreate( 9 6 sourcePlatform: string, 10 7 sourceUsername: string, ··· 22 19 return (result as any[])[0].id; 23 20 } 24 21 25 - /** 26 - * Bulk create source accounts 27 - **/ 28 22 async bulkCreate( 29 23 sourcePlatform: string, 30 24 usernames: string[], 31 25 ): Promise<Map<string, number>> { 32 - const values = usernames.map((username) => ({ 33 - platform: sourcePlatform, 34 - username: username, 35 - normalized: normalize(username), 36 - })); 26 + // Prepare data 27 + const rows = usernames.map((username) => [ 28 + sourcePlatform, 29 + username, 30 + normalize(username), 31 + ]); 37 32 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); 33 + // Use helper to build UNNEST arrays 34 + const [platforms, sourceUsernames, normalized] = this.buildUnnestArrays( 35 + ["source_platform", "source_username", "normalized_username"], 36 + rows, 37 + ); 41 38 39 + // Execute with Neon's template syntax 42 40 const result = await this.sql` 43 41 INSERT INTO source_accounts (source_platform, source_username, normalized_username) 44 - SELECT * 45 - FROM UNNEST( 42 + SELECT * FROM UNNEST( 46 43 ${platforms}::text[], 47 - ${source_usernames}::text[], 44 + ${sourceUsernames}::text[], 48 45 ${normalized}::text[] 49 46 ) AS t(source_platform, source_username, normalized_username) 50 47 ON CONFLICT (source_platform, normalized_username) DO UPDATE ··· 52 49 RETURNING id, normalized_username 53 50 `; 54 51 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; 52 + // Use helper to build result map 53 + return this.buildIdMap(result as any[], "normalized_username", "id"); 62 54 } 63 55 64 - /** 65 - * Mark source accounts as matched 66 - **/ 67 56 async markAsMatched(sourceAccountIds: number[]): Promise<void> { 68 57 if (sourceAccountIds.length === 0) return; 69 58 ··· 74 63 `; 75 64 } 76 65 77 - /** 78 - * Link user to source accounts 79 - **/ 80 66 async linkUserToAccounts( 81 67 uploadId: string, 82 68 did: string, 83 69 links: Array<{ sourceAccountId: number; sourceDate: string }>, 84 70 ): Promise<void> { 85 - const numLinks = links.length; 86 - if (numLinks === 0) return; 71 + if (links.length === 0) return; 72 + 73 + const rows = links.map((l) => [ 74 + uploadId, 75 + did, 76 + l.sourceAccountId, 77 + l.sourceDate, 78 + ]); 87 79 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); 80 + const [uploadIds, dids, sourceAccountIds, sourceDates] = 81 + this.buildUnnestArrays( 82 + ["upload_id", "did", "source_account_id", "source_date"], 83 + rows, 84 + ); 92 85 93 86 await this.sql` 94 87 INSERT INTO user_source_follows (upload_id, did, source_account_id, source_date)
+1 -1
netlify/functions/shared/repositories/UploadRepository.ts netlify/functions/repositories/UploadRepository.ts
··· 1 1 import { BaseRepository } from "./BaseRepository"; 2 - import { UserUploadRow } from "../types"; 2 + import { UserUploadRow } from "../core/types"; 3 3 4 4 export class UploadRepository extends BaseRepository { 5 5 /**
-1
netlify/functions/shared/repositories/index.ts netlify/functions/repositories/index.ts
··· 1 - export * from "./BaseRepository"; 2 1 export * from "./UploadRepository"; 3 2 export * from "./SourceAccountRepository"; 4 3 export * from "./MatchRepository";
+4 -13
netlify/functions/shared/services/database/DatabaseService.ts netlify/functions/infrastructure/database/DatabaseService.ts
··· 1 - import { getDbClient } from "./connection"; 2 - import { DatabaseError } from "../../constants/errors"; 1 + import { getDbClient } from "./DatabaseConnection"; 2 + import { DatabaseError } from "../../core/errors"; 3 + import { DbStatusRow } from "../../core/types"; 3 4 4 5 export class DatabaseService { 5 6 private sql = getDbClient(); ··· 11 12 process.env.NETLIFY_DATABASE_URL?.split("@")[1], 12 13 ); 13 14 14 - // Test connection 15 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 - >[]; 16 + .sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`) as DbStatusRow[]; 20 17 console.log("✅ Connected:", res[0]); 21 18 22 - // Create tables 23 19 await this.createTables(); 24 20 await this.createIndexes(); 25 21 ··· 34 30 } 35 31 36 32 private async createTables(): Promise<void> { 37 - // OAuth Tables 38 33 await this.sql` 39 34 CREATE TABLE IF NOT EXISTS oauth_states ( 40 35 key TEXT PRIMARY KEY, ··· 62 57 ) 63 58 `; 64 59 65 - // User + Match Tracking 66 60 await this.sql` 67 61 CREATE TABLE IF NOT EXISTS user_uploads ( 68 62 upload_id TEXT PRIMARY KEY, ··· 156 150 } 157 151 158 152 private async createIndexes(): Promise<void> { 159 - // Existing indexes 160 153 await this 161 154 .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 162 155 await this ··· 175 168 .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`; 176 169 await this 177 170 .sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 178 - 179 - // Enhanced indexes 180 171 await this 181 172 .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 173 await this
-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";
+7 -8
netlify/functions/shared/services/oauth/client.factory.ts netlify/functions/infrastructure/oauth/OAuthClientFactory.ts
··· 3 3 atprotoLoopbackClientMetadata, 4 4 } from "@atproto/oauth-client-node"; 5 5 import { JoseKey } from "@atproto/jwk-jose"; 6 - import { stateStore, sessionStore } from "../session/stores"; 6 + import { ApiError } from "../../core/errors"; 7 + import { stateStore, sessionStore } from "./stores"; 7 8 import { getOAuthConfig } from "./config"; 8 9 9 10 function normalizePrivateKey(key: string): string { ··· 13 14 return key; 14 15 } 15 16 16 - /** 17 - * Creates and returns a configured OAuth client based on environment 18 - * Centralizes the client creation logic used across all endpoints 19 - **/ 20 17 export async function createOAuthClient(event?: { 21 18 headers: Record<string, string | undefined>; 22 19 }): Promise<NodeOAuthClient> { ··· 24 21 const isDev = config.clientType === "loopback"; 25 22 26 23 if (isDev) { 27 - // Loopback mode for local development 28 24 console.log("[oauth-client] Creating loopback OAuth client"); 29 25 const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 30 26 ··· 34 30 sessionStore: sessionStore as any, 35 31 }); 36 32 } else { 37 - // Production mode with private key 38 33 console.log("[oauth-client] Creating production OAuth client"); 39 34 40 35 if (!process.env.OAUTH_PRIVATE_KEY) { 41 - throw new Error("OAUTH_PRIVATE_KEY is required for production"); 36 + throw new ApiError( 37 + "OAuth client key missing", 38 + 500, 39 + "OAUTH_PRIVATE_KEY environment variable is required for production client setup.", 40 + ); 42 41 } 43 42 44 43 const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY);
+8 -12
netlify/functions/shared/services/oauth/config.ts netlify/functions/infrastructure/oauth/config.ts
··· 1 - import { OAuthConfig } from "../../types"; 2 - import { configCache } from "../../utils/cache.utils"; 1 + import { OAuthConfig } from "../../core/types"; 2 + import { ApiError } from "../../core/errors"; 3 + import { configCache } from "../cache/CacheService"; 3 4 4 5 export function getOAuthConfig(event?: { 5 6 headers: Record<string, string | undefined>; 6 7 }): OAuthConfig { 7 - // Create a cache key based on the environment 8 8 const host = event?.headers?.host || "default"; 9 9 const cacheKey = `oauth-config-${host}`; 10 10 11 - // Check cache first 12 11 const cached = configCache.get(cacheKey) as OAuthConfig | undefined; 13 12 if (cached) { 14 13 return cached; ··· 18 17 let deployContext: string | undefined; 19 18 20 19 if (event?.headers) { 21 - // Get deploy context from Netlify headers 22 20 deployContext = event.headers["x-nf-deploy-context"]; 23 - 24 - // For Netlify deploys, construct URL from host header 25 21 const forwardedProto = event.headers["x-forwarded-proto"] || "https"; 26 22 27 23 if (host && !host.includes("localhost") && !host.includes("127.0.0.1")) { ··· 29 25 } 30 26 } 31 27 32 - // Fallback to environment variables (prioritize DEPLOY_URL over URL for preview deploys) 33 28 if (!baseUrl) { 34 29 baseUrl = process.env.DEPLOY_URL || process.env.URL; 35 30 } ··· 44 39 }, 45 40 }); 46 41 47 - // Development: loopback client for local dev 48 42 const isLocalhost = 49 43 !baseUrl || baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1"); 50 44 ··· 69 63 clientType: "loopback", 70 64 }; 71 65 } else { 72 - // Production/Preview: discoverable client 73 66 if (!baseUrl) { 74 - throw new Error("No public URL available for OAuth configuration"); 67 + throw new ApiError( 68 + "No public URL available for OAuth configuration", 69 + 500, 70 + "Missing DEPLOY_URL or URL environment variables.", 71 + ); 75 72 } 76 73 77 74 console.log("Using confidential OAuth client for:", baseUrl); ··· 85 82 }; 86 83 } 87 84 88 - // Cache the config for 5 minutes (300000ms) 89 85 configCache.set(cacheKey, config, 300000); 90 86 91 87 return config;
-2
netlify/functions/shared/services/oauth/index.ts
··· 1 - export * from "./config"; 2 - export * from "./client.factory";
-93
netlify/functions/shared/services/session/SessionService.ts
··· 1 - import { Agent } from "@atproto/api"; 2 - import { createOAuthClient } from "../oauth/client.factory"; 3 - import { userSessions } from "./stores"; 4 - import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 5 - import { AuthenticationError, ERROR_MESSAGES } from "../../constants/errors"; 6 - 7 - /** 8 - * Session Manager - Coordinates between user sessions and OAuth sessions 9 - * Provides a clean interface for session operations across the application 10 - **/ 11 - export class SessionService { 12 - /** 13 - * Get an authenticated Agent for a given session ID 14 - * Handles both user session lookup and OAuth session restoration 15 - **/ 16 - static async getAgentForSession(sessionId: string): Promise<{ 17 - agent: Agent; 18 - did: string; 19 - client: NodeOAuthClient; 20 - }> { 21 - console.log("[SessionService] Getting agent for session:", sessionId); 22 - 23 - // Get user session 24 - const userSession = await userSessions.get(sessionId); 25 - if (!userSession) { 26 - throw new AuthenticationError(ERROR_MESSAGES.INVALID_SESSION); 27 - } 28 - 29 - const did = userSession.did; 30 - console.log("[SessionService] Found user session for DID:", did); 31 - 32 - // Create OAuth client 33 - const client = await createOAuthClient(); 34 - 35 - // Restore OAuth session 36 - const oauthSession = await client.restore(did); 37 - console.log("[SessionService] Restored OAuth session for DID:", did); 38 - 39 - // Create agent from OAuth session 40 - const agent = new Agent(oauthSession); 41 - 42 - return { agent, did, client }; 43 - } 44 - 45 - /** 46 - * Delete a session and clean up associated OAuth sessions 47 - * Ensures both user_sessions and oauth_sessions are cleaned up 48 - **/ 49 - static async deleteSession(sessionId: string): Promise<void> { 50 - console.log("[SessionService] Deleting session:", sessionId); 51 - 52 - // Get user session first 53 - const userSession = await userSessions.get(sessionId); 54 - if (!userSession) { 55 - console.log("[SessionService] Session not found:", sessionId); 56 - return; 57 - } 58 - 59 - const did = userSession.did; 60 - 61 - try { 62 - // Create OAuth client and revoke the session 63 - const client = await createOAuthClient(); 64 - 65 - // Try to revoke at the PDS (this also deletes from oauth_sessions) 66 - await client.revoke(did); 67 - console.log("[SessionService] Revoked OAuth session for DID:", did); 68 - } catch (error) { 69 - // If revocation fails, the OAuth session might already be invalid 70 - console.log("[SessionService] Could not revoke OAuth session:", error); 71 - } 72 - 73 - // Delete user session 74 - await userSessions.del(sessionId); 75 - console.log("[SessionService] Deleted user session:", sessionId); 76 - } 77 - 78 - /** 79 - * Verify a session exists and is valid 80 - **/ 81 - static async verifySession(sessionId: string): Promise<boolean> { 82 - const userSession = await userSessions.get(sessionId); 83 - return userSession !== undefined; 84 - } 85 - 86 - /** 87 - * Get the DID for a session without creating an agent 88 - **/ 89 - static async getDIDForSession(sessionId: string): Promise<string | null> { 90 - const userSession = await userSessions.get(sessionId); 91 - return userSession?.did || null; 92 - } 93 - }
-2
netlify/functions/shared/services/session/index.ts
··· 1 - export * from "./SessionService"; 2 - export * from "./stores";
+3 -3
netlify/functions/shared/services/session/stores/SessionStore.ts netlify/functions/infrastructure/oauth/stores/SessionStore.ts
··· 1 - import { getDbClient } from "../../database/connection"; 2 - import { SessionData, OAuthSessionRow } from "../../../types"; 3 - import { CONFIG } from "../../../constants"; 1 + import { getDbClient } from "../../database"; 2 + import { SessionData, OAuthSessionRow } from "../../../core/types"; 3 + import { CONFIG } from "../../../core/config/constants"; 4 4 5 5 export class PostgresSessionStore { 6 6 private sql = getDbClient();
+3 -3
netlify/functions/shared/services/session/stores/StateStore.ts netlify/functions/infrastructure/oauth/stores/StateStore.ts
··· 1 - import { getDbClient } from "../../database/connection"; 2 - import { StateData, OAuthStateRow } from "../../../types"; 3 - import { CONFIG } from "../../../constants"; 1 + import { getDbClient } from "../../database"; 2 + import { StateData, OAuthStateRow } from "../../../core/types"; 3 + import { CONFIG } from "../../../core/config/constants"; 4 4 5 5 export class PostgresStateStore { 6 6 private sql = getDbClient();
+3 -3
netlify/functions/shared/services/session/stores/UserSessionStore.ts netlify/functions/infrastructure/oauth/stores/UserSessionStore.ts
··· 1 - import { getDbClient } from "../../database/connection"; 2 - import { UserSessionData, UserSessionRow } from "../../../types"; 3 - import { CONFIG } from "../../../constants"; 1 + import { getDbClient } from "../../database"; 2 + import { UserSessionData, UserSessionRow } from "../../../core/types"; 3 + import { CONFIG } from "../../../core/config/constants"; 4 4 5 5 export class PostgresUserSessionStore { 6 6 private sql = getDbClient();
netlify/functions/shared/services/session/stores/index.ts netlify/functions/infrastructure/oauth/stores/index.ts
netlify/functions/shared/types/api.types.ts netlify/functions/core/types/api.types.ts
+6
netlify/functions/shared/types/database.types.ts netlify/functions/core/types/database.types.ts
··· 90 90 dismissed_at: Date | null; 91 91 } 92 92 93 + export interface DbStatusRow { 94 + db: string; 95 + user: string; 96 + now: Date; 97 + } 98 + 93 99 export interface NotificationQueueRow { 94 100 id: number; 95 101 did: string;
netlify/functions/shared/types/index.ts netlify/functions/core/types/index.ts
-49
netlify/functions/shared/utils/cache.utils.ts
··· 1 - /** 2 - * Simple in-memory cache with TTL support 3 - **/ 4 - class SimpleCache<T> { 5 - private cache = new Map<string, { value: T; expires: number }>(); 6 - 7 - set(key: string, value: T, ttlMs: number): void { 8 - this.cache.set(key, { 9 - value, 10 - expires: Date.now() + ttlMs, 11 - }); 12 - } 13 - 14 - get(key: string): T | undefined { 15 - const entry = this.cache.get(key); 16 - if (!entry) return undefined; 17 - 18 - if (Date.now() > entry.expires) { 19 - this.cache.delete(key); 20 - return undefined; 21 - } 22 - 23 - return entry.value; 24 - } 25 - 26 - has(key: string): boolean { 27 - return this.get(key) !== undefined; 28 - } 29 - 30 - delete(key: string): void { 31 - this.cache.delete(key); 32 - } 33 - 34 - clear(): void { 35 - this.cache.clear(); 36 - } 37 - 38 - // Clean up expired entries 39 - cleanup(): void { 40 - const now = Date.now(); 41 - for (const [key, entry] of this.cache.entries()) { 42 - if (now > entry.expires) { 43 - this.cache.delete(key); 44 - } 45 - } 46 - } 47 - } 48 - 49 - export const configCache = new SimpleCache<any>();
-1
netlify/functions/shared/utils/index.ts netlify/functions/utils/index.ts
··· 1 1 export * from "./response.utils"; 2 - export * from "./cache.utils"; 3 2 export * from "./string.utils";
+1 -1
netlify/functions/shared/utils/response.utils.ts netlify/functions/utils/response.utils.ts
··· 1 1 import { HandlerResponse } from "@netlify/functions"; 2 - import { ApiResponse } from "../types"; 2 + import { ApiResponse } from "../core/types"; 3 3 4 4 export function successResponse<T>( 5 5 data: T,
netlify/functions/shared/utils/string.utils.ts netlify/functions/utils/string.utils.ts
+2 -2
src/App.tsx
··· 4 4 import HomePage from "./pages/Home"; 5 5 import LoadingPage from "./pages/Loading"; 6 6 import ResultsPage from "./pages/Results"; 7 - import { apiClient } from "./lib/apiClient"; 8 7 import { useAuth } from "./hooks/useAuth"; 9 8 import { useSearch } from "./hooks/useSearch"; 10 9 import { useFollow } from "./hooks/useFollows"; 11 10 import { useFileUpload } from "./hooks/useFileUpload"; 12 11 import { useTheme } from "./hooks/useTheme"; 13 12 import Firefly from "./components/Firefly"; 14 - import { ATPROTO_APPS } from "./constants/atprotoApps"; 15 13 import { DEFAULT_SETTINGS } from "./types/settings"; 16 14 import type { UserSettings } from "./types/settings"; 15 + import { apiClient } from "./lib/api/client"; 16 + import { ATPROTO_APPS } from "./config/atprotoApps"; 17 17 18 18 export default function App() { 19 19 // Auth hook
+6 -13
src/components/AppHeader.tsx
··· 3 3 import { Heart, Home, LogOut, ChevronDown } from "lucide-react"; 4 4 import ThemeControls from "./ThemeControls"; 5 5 import FireflyLogo from "../assets/at-firefly-logo.svg?react"; 6 + import AvatarWithFallback from "./common/AvatarWithFallback"; 6 7 7 8 interface atprotoSession { 8 9 did: string; ··· 93 94 onClick={() => setShowMenu(!showMenu)} 94 95 className="flex items-center space-x-3 px-3 py-1 rounded-lg hover:bg-purple-50 dark:hover:bg-slate-800 transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-amber-400" 95 96 > 96 - {session?.avatar ? ( 97 - <img 98 - src={session.avatar} 99 - alt="" 100 - className="w-8 h-8 rounded-full object-cover" 101 - /> 102 - ) : ( 103 - <div className="w-8 h-8 bg-gradient-to-br from-cyan-400 to-purple-500 rounded-full flex items-center justify-center shadow-sm"> 104 - <span className="text-white font-bold text-sm"> 105 - {session?.handle?.charAt(0).toUpperCase()} 106 - </span> 107 - </div> 108 - )} 97 + <AvatarWithFallback 98 + avatar={session?.avatar} 99 + handle={session?.handle || ""} 100 + size="sm" 101 + /> 109 102 <span className="text-sm font-medium text-purple-950 dark:text-cyan-50 hidden sm:inline"> 110 103 @{session?.handle} 111 104 </span>
+1 -3
src/components/FaviconIcon.tsx
··· 1 - // FaviconIcon.tsx (Conceptual Component Update) 2 - 3 1 import { useState } from "react"; 4 2 import { Globe } from "lucide-react"; 5 3 ··· 7 5 url: string; 8 6 alt: string; 9 7 className?: string; 10 - useButtonStyling?: boolean; // ⬅️ NEW OPTIONAL PROP 8 + useButtonStyling?: boolean; 11 9 } 12 10 13 11 export default function FaviconIcon({
+3 -8
src/components/HistoryTab.tsx
··· 1 1 import { Upload, Sparkles, ChevronRight, Database } from "lucide-react"; 2 - import { ATPROTO_APPS } from "../constants/atprotoApps"; 2 + import { ATPROTO_APPS } from "../config/atprotoApps"; 3 3 import type { Upload as UploadType } from "../types"; 4 4 import FaviconIcon from "../components/FaviconIcon"; 5 5 import type { UserSettings } from "../types/settings"; 6 6 import { getPlatformColor } from "../lib/utils/platform"; 7 + import { formatRelativeTime } from "../lib/utils/date"; 7 8 8 9 interface HistoryTabProps { 9 10 uploads: UploadType[]; ··· 24 25 }: HistoryTabProps) { 25 26 const formatDate = (dateString: string) => { 26 27 const date = new Date(dateString); 27 - return date.toLocaleDateString("en-US", { 28 - month: "short", 29 - day: "numeric", 30 - year: "numeric", 31 - hour: "2-digit", 32 - minute: "2-digit", 33 - }); 28 + return formatRelativeTime(date.toLocaleDateString("en-US")); 34 29 }; 35 30 36 31 return (
+1 -2
src/components/PlatformSelector.tsx
··· 1 - import { Twitter, Instagram, Video, Hash, Gamepad2 } from "lucide-react"; 2 - import { PLATFORMS } from "../constants/platforms"; 1 + import { PLATFORMS } from "../config/platforms"; 3 2 4 3 interface PlatformSelectorProps { 5 4 onPlatformSelect: (platform: string) => void;
+7 -14
src/components/SearchResultCard.tsx
··· 6 6 UserCheck, 7 7 } from "lucide-react"; 8 8 import type { SearchResult } from "../types"; 9 - import { getPlatform, getAtprotoAppWithFallback } from "../lib/utils/platform"; 9 + import { getAtprotoAppWithFallback } from "../lib/utils/platform"; 10 10 import type { AtprotoAppId } from "../types/settings"; 11 + import AvatarWithFallback from "./common/AvatarWithFallback"; 11 12 12 13 interface SearchResultCardProps { 13 14 result: SearchResult; ··· 78 79 className="flex items-start gap-3 p-3 cursor-pointer hover:scale-[1.01] transition-transform" 79 80 > 80 81 {/* Avatar */} 81 - {match.avatar ? ( 82 - <img 83 - src={match.avatar} 84 - alt="User avatar" 85 - className="w-12 h-12 rounded-full object-cover flex-shrink-0" 86 - /> 87 - ) : ( 88 - <div className="w-12 h-12 rounded-full bg-gradient-to-br from-cyan-400 to-purple-500 flex items-center justify-center flex-shrink-0"> 89 - <span className="text-white font-bold"> 90 - {match.handle.charAt(0).toUpperCase()} 91 - </span> 92 - </div> 93 - )} 82 + <AvatarWithFallback 83 + avatar={match.avatar} 84 + handle={match.handle || ""} 85 + size="sm" 86 + /> 94 87 95 88 {/* Match Info */} 96 89 <div className="flex-1 min-w-0 space-y-1">
+2 -2
src/components/SetupWizard.tsx
··· 1 1 import { useState } from "react"; 2 2 import { Heart, X, Check, ChevronRight } from "lucide-react"; 3 - import { PLATFORMS } from "../constants/platforms"; 4 - import { ATPROTO_APPS } from "../constants/atprotoApps"; 3 + import { PLATFORMS } from "../config/platforms"; 4 + import { ATPROTO_APPS } from "../config/atprotoApps"; 5 5 import type { UserSettings, PlatformDestinations } from "../types/settings"; 6 6 7 7 interface SetupWizardProps {
+41
src/components/common/AvatarWithFallback.tsx
··· 1 + interface AvatarWithFallbackProps { 2 + avatar?: string; 3 + handle: string; 4 + size?: "sm" | "md" | "lg"; 5 + className?: string; 6 + } 7 + 8 + export default function AvatarWithFallback({ 9 + avatar, 10 + handle, 11 + size = "md", 12 + className = "", 13 + }: AvatarWithFallbackProps) { 14 + const sizeClasses = { 15 + sm: "w-8 h-8 text-sm", 16 + md: "w-12 h-12 text-base", 17 + lg: "w-16 h-16 text-xl", 18 + }; 19 + 20 + const sizeClass = sizeClasses[size]; 21 + 22 + if (avatar) { 23 + return ( 24 + <img 25 + src={avatar} 26 + alt={`${handle}'s avatar`} 27 + className={`${sizeClass} rounded-full object-cover ${className}`} 28 + /> 29 + ); 30 + } 31 + 32 + return ( 33 + <div 34 + className={`${sizeClass} bg-gradient-to-br from-cyan-400 to-purple-500 rounded-full flex items-center justify-center shadow-sm ${className}`} 35 + > 36 + <span className="text-white font-bold"> 37 + {handle.charAt(0).toUpperCase()} 38 + </span> 39 + </div> 40 + ); 41 + }
+17
src/config/constants.ts
··· 1 + export const SEARCH_CONFIG = { 2 + BATCH_SIZE: 25, 3 + MAX_MATCHES: 1000, 4 + } as const; 5 + 6 + export const FOLLOW_CONFIG = { 7 + BATCH_SIZE: 50, 8 + } as const; 9 + 10 + export const CACHE_CONFIG = { 11 + DEFAULT_TTL: 5 * 60 * 1000, // 5 minutes 12 + PROFILE_TTL: 5 * 60 * 1000, 13 + UPLOAD_LIST_TTL: 2 * 60 * 1000, 14 + UPLOAD_DETAILS_TTL: 10 * 60 * 1000, 15 + SEARCH_RESULTS_TTL: 10 * 60 * 1000, 16 + FOLLOW_STATUS_TTL: 2 * 60 * 1000, 17 + } as const;
+31
src/config/env.ts
··· 1 + /** 2 + * Environment configuration 3 + * Centralizes all environment variable access and validation 4 + */ 5 + 6 + // Determine environment 7 + const nodeEnv = import.meta.env.MODE || "development"; 8 + 9 + export const ENV = { 10 + // Environment 11 + NODE_ENV: nodeEnv, 12 + IS_DEVELOPMENT: nodeEnv === "development", 13 + IS_PRODUCTION: nodeEnv === "production", 14 + IS_TEST: nodeEnv === "test", 15 + 16 + // Feature flags 17 + IS_LOCAL_MOCK: import.meta.env.VITE_LOCAL_MOCK === "true", 18 + ENABLE_OAUTH: import.meta.env.VITE_ENABLE_OAUTH !== "false", 19 + ENABLE_DATABASE: import.meta.env.VITE_ENABLE_DATABASE !== "false", 20 + 21 + // API 22 + API_BASE: import.meta.env.VITE_API_BASE || "/.netlify/functions", 23 + } as const; 24 + 25 + export function isLocalMockMode(): boolean { 26 + return ENV.IS_LOCAL_MOCK; 27 + } 28 + 29 + export function getApiUrl(endpoint: string): string { 30 + return `${ENV.API_BASE}/${endpoint}`; 31 + }
src/constants/atprotoApps.ts src/config/atprotoApps.ts
-21
src/constants/platforms.ts src/config/platforms.ts
··· 75 75 defaultApp: "bluesky", 76 76 }, 77 77 }; 78 - 79 - export const SEARCH_CONFIG = { 80 - BATCH_SIZE: 25, 81 - MAX_MATCHES: 1000, 82 - }; 83 - 84 - export const FOLLOW_CONFIG = { 85 - BATCH_SIZE: 50, 86 - }; 87 - 88 - /** 89 - * @deprecated Use getPlatformColor from lib/utils/platform instead 90 - **/ 91 - export function getLegacyPlatformColor(platform: string): string { 92 - const colors: Record<string, string> = { 93 - tiktok: "from-black via-gray-800 to-cyan-400", 94 - twitter: "from-blue-400 to-blue-600", 95 - instagram: "from-pink-500 via-purple-500 to-orange-500", 96 - }; 97 - return colors[platform] || "from-gray-400 to-gray-600"; 98 - }
+4 -22
src/hooks/useAuth.ts
··· 1 1 import { useState, useEffect } from "react"; 2 - import { apiClient } from "../lib/apiClient"; 2 + import { apiClient } from "../lib/api/client"; 3 3 import type { AtprotoSession, AppStep } from "../types"; 4 4 5 5 export function useAuth() { ··· 26 26 return; 27 27 } 28 28 29 - // If we have a session parameter in URL, this is an OAuth callback 30 29 if (sessionId) { 31 30 console.log("[useAuth] Session ID in URL:", sessionId); 32 31 setStatusMessage("Loading your session..."); 33 32 34 - // Single call now gets both session AND profile data 35 33 const data = await apiClient.getSession(); 36 - setSession({ 37 - did: data.did, 38 - handle: data.handle, 39 - displayName: data.displayName, 40 - avatar: data.avatar, 41 - description: data.description, 42 - }); 34 + setSession(data); 43 35 setCurrentStep("home"); 44 36 setStatusMessage(`Welcome back, ${data.handle}!`); 45 37 ··· 47 39 return; 48 40 } 49 41 50 - // Otherwise, check if there's an existing session cookie 51 - // Single call now gets both session AND profile data 52 42 console.log("[useAuth] Checking for existing session cookie..."); 53 43 const data = await apiClient.getSession(); 54 44 console.log("[useAuth] Found existing session:", data); 55 - setSession({ 56 - did: data.did, 57 - handle: data.handle, 58 - displayName: data.displayName, 59 - avatar: data.avatar, 60 - description: data.description, 61 - }); 45 + setSession(data); 62 46 setCurrentStep("home"); 63 47 setStatusMessage(`Welcome back, ${data.handle}!`); 64 48 } catch (error) { ··· 83 67 84 68 async function logout() { 85 69 try { 86 - console.log("[useAuth] Cookies before logout:", document.cookie); 87 70 console.log("[useAuth] Starting logout..."); 88 71 setStatusMessage("Logging out..."); 89 72 await apiClient.logout(); 90 - console.log("[useAuth] Cookies after logout:", document.cookie); 91 73 92 - apiClient.cache.clear(); // Clear client-side cache 74 + apiClient.cache.clear(); 93 75 console.log("[useAuth] Cache cleared"); 94 76 setSession(null); 95 77 setCurrentStep("login");
+1 -2
src/hooks/useFileUpload.ts
··· 1 - import { parseDataFile } from "../lib/fileExtractor"; 1 + import { parseDataFile } from "../lib/parsers/fileExtractor"; 2 2 import type { SearchResult, UserSettings } from "../types"; 3 3 4 4 export function useFileUpload( ··· 41 41 return; 42 42 } 43 43 44 - // Initialize search results - convert usernames to SearchResult format 45 44 const initialResults: SearchResult[] = usernames.map((username) => ({ 46 45 sourceUser: { 47 46 username: username,
+8 -23
src/hooks/useFollows.ts
··· 1 1 import { useState } from "react"; 2 - import { apiClient } from "../lib/apiClient"; 3 - import { FOLLOW_CONFIG } from "../constants/platforms"; 4 - import { ATPROTO_APPS } from "../constants/atprotoApps"; 2 + import { apiClient } from "../lib/api/client"; 3 + import { FOLLOW_CONFIG } from "../config/constants"; 4 + import { getAtprotoApp } from "../lib/utils/platform"; 5 5 import type { SearchResult, AtprotoSession, AtprotoAppId } from "../types"; 6 6 7 7 export function useFollow( ··· 20 20 ): Promise<void> { 21 21 if (!session || isFollowing) return; 22 22 23 - // Determine source platform for results 24 - const followLexicon = ATPROTO_APPS[destinationAppId]?.followLexicon; 25 - const destinationName = 26 - ATPROTO_APPS[destinationAppId]?.name || "Undefined App"; 23 + const destinationApp = getAtprotoApp(destinationAppId); 24 + const followLexicon = 25 + destinationApp?.followLexicon || "app.bsky.graph.follow"; 26 + const destinationName = destinationApp?.name || "Undefined App"; 27 27 28 - if (!followLexicon) { 29 - onUpdate( 30 - `Error: Invalid destination app or lexicon for ${destinationAppId}`, 31 - ); 32 - return; 33 - } 34 - 35 - // Get selected users 36 28 const selectedUsers = searchResults.flatMap((result, resultIndex) => 37 29 result.atprotoMatches 38 30 .filter((match) => result.selectedMatches?.has(match.did)) ··· 46 38 return; 47 39 } 48 40 49 - // Check follow status before attempting to follow 50 41 setIsCheckingFollowStatus(true); 51 42 onUpdate(`Checking follow status for ${selectedUsers.length} users...`); 52 43 ··· 56 47 followStatusMap = await apiClient.checkFollowStatus(dids, followLexicon); 57 48 } catch (error) { 58 49 console.error("Failed to check follow status:", error); 59 - // Continue without filtering - backend will handle duplicates 60 50 } finally { 61 51 setIsCheckingFollowStatus(false); 62 52 } 63 53 64 - // Filter out users already being followed 65 54 const usersToFollow = selectedUsers.filter( 66 55 (user) => !followStatusMap[user.did], 67 56 ); ··· 72 61 `${alreadyFollowingCount} user${alreadyFollowingCount > 1 ? "s" : ""} already followed. Following ${usersToFollow.length} remaining...`, 73 62 ); 74 63 75 - // Update UI to show already followed status 76 64 setSearchResults((prev) => 77 65 prev.map((result) => ({ 78 66 ...result, ··· 116 104 totalFollowed += data.succeeded; 117 105 totalFailed += data.failed; 118 106 119 - // Mark successful follows in UI 120 107 data.results.forEach((result) => { 121 108 if (result.success) { 122 109 const user = batch.find((u) => u.did === result.did); ··· 131 118 match.did === result.did 132 119 ? { 133 120 ...match, 134 - followed: true, // Backward compatibility 121 + followed: true, 135 122 followStatus: { 136 123 ...match.followStatus, 137 124 [followLexicon]: true, ··· 154 141 totalFailed += batch.length; 155 142 console.error("Batch follow error:", error); 156 143 } 157 - 158 - // Rate limit handling is in the backend 159 144 } 160 145 161 146 const finalMsg =
+2 -35
src/hooks/useSearch.ts
··· 1 1 import { useState } from "react"; 2 - import { apiClient } from "../lib/apiClient"; 3 - import { SEARCH_CONFIG } from "../constants/platforms"; 2 + import { apiClient } from "../lib/api/client"; 3 + import { SEARCH_CONFIG } from "../config/constants"; 4 4 import type { SearchResult, SearchProgress, AtprotoSession } from "../types"; 5 5 6 6 export function useSearch(session: AtprotoSession | null) { ··· 47 47 const batch = resultsToSearch.slice(i, i + BATCH_SIZE); 48 48 const usernames = batch.map((r) => r.sourceUser.username); 49 49 50 - // Mark current batch as searching 51 50 setSearchResults((prev) => 52 51 prev.map((result, index) => 53 52 i <= index && index < i + BATCH_SIZE ··· 62 61 followLexicon, 63 62 ); 64 63 65 - // Reset error counter on success 66 64 consecutiveErrors = 0; 67 65 68 - // Process batch results 69 66 data.results.forEach((result) => { 70 67 totalSearched++; 71 68 if (result.actors.length > 0) { ··· 82 79 `Searched ${totalSearched} of ${resultsToSearch.length} users. Found ${totalFound} matches.`, 83 80 ); 84 81 85 - // Update results 86 - setSearchResults((prev) => 87 - prev.map((result, index) => { 88 - const batchResultIndex = index - i; 89 - if ( 90 - batchResultIndex >= 0 && 91 - batchResultIndex < data.results.length 92 - ) { 93 - const batchResult = data.results[batchResultIndex]; 94 - const newSelectedMatches = new Set<string>(); 95 - 96 - // Auto-select only the first (highest scoring) match 97 - if (batchResult.actors.length > 0) { 98 - newSelectedMatches.add(batchResult.actors[0].did); 99 - } 100 - 101 - return { 102 - ...result, 103 - atprotoMatches: batchResult.actors, 104 - isSearching: false, 105 - error: batchResult.error, 106 - selectedMatches: newSelectedMatches, 107 - }; 108 - } 109 - return result; 110 - }), 111 - ); 112 - 113 82 setSearchResults((prev) => 114 83 prev.map((result, index) => { 115 84 const batchResultIndex = index - i; ··· 143 112 console.error("Batch search error:", error); 144 113 consecutiveErrors++; 145 114 146 - // Mark batch as failed 147 115 setSearchResults((prev) => 148 116 prev.map((result, index) => 149 117 i <= index && index < i + BATCH_SIZE ··· 152 120 ), 153 121 ); 154 122 155 - // If we hit rate limits or repeated errors, add exponential backoff 156 123 if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { 157 124 const backoffDelay = Math.min( 158 125 1000 * Math.pow(2, consecutiveErrors - MAX_CONSECUTIVE_ERRORS),
+86
src/lib/api/IApiClient.ts
··· 1 + import type { 2 + AtprotoSession, 3 + BatchSearchResult, 4 + BatchFollowResult, 5 + SaveResultsResponse, 6 + SearchResult, 7 + } from "../../types"; 8 + 9 + /** 10 + * API Client Interface 11 + * Defines the contract that all API implementations must follow 12 + **/ 13 + export interface IApiClient { 14 + // Authentication 15 + startOAuth(handle: string): Promise<{ url: string }>; 16 + getSession(): Promise<AtprotoSession>; 17 + logout(): Promise<void>; 18 + 19 + // Upload History 20 + getUploads(): Promise<{ 21 + uploads: Array<{ 22 + uploadId: string; 23 + sourcePlatform: string; 24 + createdAt: string; 25 + totalUsers: number; 26 + matchedUsers: number; 27 + unmatchedUsers: number; 28 + }>; 29 + }>; 30 + 31 + getUploadDetails( 32 + uploadId: string, 33 + page?: number, 34 + pageSize?: number, 35 + ): Promise<{ 36 + results: SearchResult[]; 37 + pagination?: { 38 + page: number; 39 + pageSize: number; 40 + totalPages: number; 41 + totalUsers: number; 42 + hasNextPage: boolean; 43 + hasPrevPage: boolean; 44 + }; 45 + }>; 46 + 47 + getAllUploadDetails(uploadId: string): Promise<{ results: SearchResult[] }>; 48 + 49 + // Search Operations 50 + batchSearchActors( 51 + usernames: string[], 52 + followLexicon?: string, 53 + ): Promise<{ results: BatchSearchResult[] }>; 54 + 55 + checkFollowStatus( 56 + dids: string[], 57 + followLexicon: string, 58 + ): Promise<Record<string, boolean>>; 59 + 60 + // Follow Operations 61 + batchFollowUsers( 62 + dids: string[], 63 + followLexicon: string, 64 + ): Promise<{ 65 + success: boolean; 66 + total: number; 67 + succeeded: number; 68 + failed: number; 69 + alreadyFollowing: number; 70 + results: BatchFollowResult[]; 71 + }>; 72 + 73 + // Save Results 74 + saveResults( 75 + uploadId: string, 76 + sourcePlatform: string, 77 + results: SearchResult[], 78 + ): Promise<SaveResultsResponse | null>; 79 + 80 + // Cache management 81 + cache: { 82 + clear: () => void; 83 + invalidate: (key: string) => void; 84 + invalidatePattern: (pattern: string) => void; 85 + }; 86 + }
+314
src/lib/api/adapters/RealApiAdapter.ts
··· 1 + import type { IApiClient } from "../IApiClient"; 2 + import type { 3 + AtprotoSession, 4 + BatchSearchResult, 5 + BatchFollowResult, 6 + SaveResultsResponse, 7 + SearchResult, 8 + } from "../../../types"; 9 + import { CacheService } from "../../../lib/utils/cache"; 10 + import { CACHE_CONFIG } from "../../../config/constants"; 11 + 12 + /** 13 + * Unwrap standardized API response format 14 + */ 15 + function unwrapResponse<T>(response: any): T { 16 + if (response.success !== undefined && response.data !== undefined) { 17 + return response.data as T; 18 + } 19 + return response as T; 20 + } 21 + 22 + /** 23 + * Real API Client Adapter 24 + * Implements actual HTTP calls to backend 25 + */ 26 + export class RealApiAdapter implements IApiClient { 27 + private responseCache = new CacheService(CACHE_CONFIG.DEFAULT_TTL); 28 + 29 + async startOAuth(handle: string): Promise<{ url: string }> { 30 + const currentOrigin = window.location.origin; 31 + 32 + const res = await fetch("/.netlify/functions/oauth-start", { 33 + method: "POST", 34 + headers: { "Content-Type": "application/json" }, 35 + body: JSON.stringify({ 36 + login_hint: handle, 37 + origin: currentOrigin, 38 + }), 39 + }); 40 + 41 + if (!res.ok) { 42 + const errorData = await res.json(); 43 + throw new Error(errorData.error || "Failed to start OAuth flow"); 44 + } 45 + 46 + const response = await res.json(); 47 + return unwrapResponse<{ url: string }>(response); 48 + } 49 + 50 + async getSession(): Promise<AtprotoSession> { 51 + const cacheKey = "session"; 52 + const cached = this.responseCache.get<AtprotoSession>(cacheKey); 53 + if (cached) { 54 + return cached; 55 + } 56 + 57 + const res = await fetch("/.netlify/functions/session", { 58 + credentials: "include", 59 + }); 60 + 61 + if (!res.ok) { 62 + throw new Error("No valid session"); 63 + } 64 + 65 + const response = await res.json(); 66 + const data = unwrapResponse<AtprotoSession>(response); 67 + 68 + this.responseCache.set(cacheKey, data, CACHE_CONFIG.PROFILE_TTL); 69 + return data; 70 + } 71 + 72 + async logout(): Promise<void> { 73 + const res = await fetch("/.netlify/functions/logout", { 74 + method: "POST", 75 + credentials: "include", 76 + }); 77 + 78 + if (!res.ok) { 79 + throw new Error("Logout failed"); 80 + } 81 + 82 + this.responseCache.clear(); 83 + } 84 + 85 + async getUploads(): Promise<{ 86 + uploads: Array<{ 87 + uploadId: string; 88 + sourcePlatform: string; 89 + createdAt: string; 90 + totalUsers: number; 91 + matchedUsers: number; 92 + unmatchedUsers: number; 93 + }>; 94 + }> { 95 + const cacheKey = "uploads"; 96 + const cached = this.responseCache.get<any>(cacheKey); 97 + if (cached) { 98 + return cached; 99 + } 100 + 101 + const res = await fetch("/.netlify/functions/get-uploads", { 102 + credentials: "include", 103 + }); 104 + 105 + if (!res.ok) { 106 + throw new Error("Failed to fetch uploads"); 107 + } 108 + 109 + const response = await res.json(); 110 + const data = unwrapResponse<any>(response); 111 + 112 + this.responseCache.set(cacheKey, data, CACHE_CONFIG.UPLOAD_LIST_TTL); 113 + return data; 114 + } 115 + 116 + async getUploadDetails( 117 + uploadId: string, 118 + page: number = 1, 119 + pageSize: number = 50, 120 + ): Promise<{ 121 + results: SearchResult[]; 122 + pagination?: any; 123 + }> { 124 + const cacheKey = `upload-details-${uploadId}-p${page}-s${pageSize}`; 125 + const cached = this.responseCache.get<any>(cacheKey); 126 + if (cached) { 127 + return cached; 128 + } 129 + 130 + const res = await fetch( 131 + `/.netlify/functions/get-upload-details?uploadId=${uploadId}&page=${page}&pageSize=${pageSize}`, 132 + { credentials: "include" }, 133 + ); 134 + 135 + if (!res.ok) { 136 + throw new Error("Failed to fetch upload details"); 137 + } 138 + 139 + const response = await res.json(); 140 + const data = unwrapResponse<any>(response); 141 + 142 + this.responseCache.set(cacheKey, data, CACHE_CONFIG.UPLOAD_DETAILS_TTL); 143 + return data; 144 + } 145 + 146 + async getAllUploadDetails( 147 + uploadId: string, 148 + ): Promise<{ results: SearchResult[] }> { 149 + const firstPage = await this.getUploadDetails(uploadId, 1, 100); 150 + 151 + if (!firstPage.pagination || firstPage.pagination.totalPages === 1) { 152 + return { results: firstPage.results }; 153 + } 154 + 155 + const allResults = [...firstPage.results]; 156 + const promises = []; 157 + 158 + for (let page = 2; page <= firstPage.pagination.totalPages; page++) { 159 + promises.push(this.getUploadDetails(uploadId, page, 100)); 160 + } 161 + 162 + const remainingPages = await Promise.all(promises); 163 + for (const pageData of remainingPages) { 164 + allResults.push(...pageData.results); 165 + } 166 + 167 + return { results: allResults }; 168 + } 169 + 170 + async checkFollowStatus( 171 + dids: string[], 172 + followLexicon: string, 173 + ): Promise<Record<string, boolean>> { 174 + const cacheKey = `follow-status-${followLexicon}-${dids.slice().sort().join(",")}`; 175 + const cached = this.responseCache.get<Record<string, boolean>>(cacheKey); 176 + if (cached) { 177 + return cached; 178 + } 179 + 180 + const res = await fetch("/.netlify/functions/check-follow-status", { 181 + method: "POST", 182 + credentials: "include", 183 + headers: { "Content-Type": "application/json" }, 184 + body: JSON.stringify({ dids, followLexicon }), 185 + }); 186 + 187 + if (!res.ok) { 188 + throw new Error("Failed to check follow status"); 189 + } 190 + 191 + const response = await res.json(); 192 + const data = unwrapResponse<{ followStatus: Record<string, boolean> }>( 193 + response, 194 + ); 195 + 196 + this.responseCache.set( 197 + cacheKey, 198 + data.followStatus, 199 + CACHE_CONFIG.FOLLOW_STATUS_TTL, 200 + ); 201 + return data.followStatus; 202 + } 203 + 204 + async batchSearchActors( 205 + usernames: string[], 206 + followLexicon?: string, 207 + ): Promise<{ results: BatchSearchResult[] }> { 208 + const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`; 209 + const cached = this.responseCache.get<any>(cacheKey); 210 + if (cached) { 211 + return cached; 212 + } 213 + 214 + const res = await fetch("/.netlify/functions/batch-search-actors", { 215 + method: "POST", 216 + credentials: "include", 217 + headers: { "Content-Type": "application/json" }, 218 + body: JSON.stringify({ usernames, followLexicon }), 219 + }); 220 + 221 + if (!res.ok) { 222 + throw new Error(`Batch search failed: ${res.status}`); 223 + } 224 + 225 + const response = await res.json(); 226 + const data = unwrapResponse<{ results: BatchSearchResult[] }>(response); 227 + 228 + this.responseCache.set(cacheKey, data, CACHE_CONFIG.SEARCH_RESULTS_TTL); 229 + return data; 230 + } 231 + 232 + async batchFollowUsers( 233 + dids: string[], 234 + followLexicon: string, 235 + ): Promise<{ 236 + success: boolean; 237 + total: number; 238 + succeeded: number; 239 + failed: number; 240 + alreadyFollowing: number; 241 + results: BatchFollowResult[]; 242 + }> { 243 + const res = await fetch("/.netlify/functions/batch-follow-users", { 244 + method: "POST", 245 + credentials: "include", 246 + headers: { "Content-Type": "application/json" }, 247 + body: JSON.stringify({ dids, followLexicon }), 248 + }); 249 + 250 + if (!res.ok) { 251 + throw new Error("Batch follow failed"); 252 + } 253 + 254 + const response = await res.json(); 255 + const data = unwrapResponse<any>(response); 256 + 257 + // Invalidate caches after following 258 + this.responseCache.invalidate("uploads"); 259 + this.responseCache.invalidatePattern("upload-details"); 260 + this.responseCache.invalidatePattern("follow-status"); 261 + 262 + return data; 263 + } 264 + 265 + async saveResults( 266 + uploadId: string, 267 + sourcePlatform: string, 268 + results: SearchResult[], 269 + ): Promise<SaveResultsResponse | null> { 270 + try { 271 + const resultsToSave = results 272 + .filter((r) => !r.isSearching) 273 + .map((r) => ({ 274 + sourceUser: r.sourceUser, 275 + atprotoMatches: r.atprotoMatches || [], 276 + })); 277 + 278 + const res = await fetch("/.netlify/functions/save-results", { 279 + method: "POST", 280 + credentials: "include", 281 + headers: { "Content-Type": "application/json" }, 282 + body: JSON.stringify({ 283 + uploadId, 284 + sourcePlatform, 285 + results: resultsToSave, 286 + }), 287 + }); 288 + 289 + if (res.ok) { 290 + const response = await res.json(); 291 + const data = unwrapResponse<SaveResultsResponse>(response); 292 + 293 + // Invalidate caches 294 + this.responseCache.invalidate("uploads"); 295 + this.responseCache.invalidatePattern("upload-details"); 296 + 297 + return data; 298 + } else { 299 + console.error("Failed to save results:", res.status); 300 + return null; 301 + } 302 + } catch (error) { 303 + console.error("Error saving results:", error); 304 + return null; 305 + } 306 + } 307 + 308 + cache = { 309 + clear: () => this.responseCache.clear(), 310 + invalidate: (key: string) => this.responseCache.delete(key), 311 + invalidatePattern: (pattern: string) => 312 + this.responseCache.invalidatePattern(pattern), 313 + }; 314 + }
+21
src/lib/api/client.ts
··· 1 + import { IApiClient } from "./IApiClient"; 2 + import { RealApiAdapter } from "./adapters/RealApiAdapter"; 3 + import { MockApiAdapter } from "./adapters/MockApiAdapter"; 4 + import { ENV } from "../../config/env"; 5 + 6 + /** 7 + * API Client Factory 8 + * Returns the appropriate implementation based on environment 9 + **/ 10 + function createApiClient(): IApiClient { 11 + if (ENV.IS_LOCAL_MOCK) { 12 + console.log("[API] Using Mock API Adapter"); 13 + return new MockApiAdapter(); 14 + } 15 + 16 + console.log("[API] Using Real API Adapter"); 17 + return new RealApiAdapter(); 18 + } 19 + 20 + // Export singleton instance 21 + export const apiClient = createApiClient();
-11
src/lib/apiClient/index.ts
··· 1 - import { isLocalMockMode } from "../config"; 2 - 3 - // Import both clients 4 - import { apiClient as realApiClient } from "./realApiClient"; 5 - import { mockApiClient } from "./mockApiClient"; 6 - 7 - // Export the appropriate client 8 - export const apiClient = isLocalMockMode() ? mockApiClient : realApiClient; 9 - 10 - // Also export both for explicit usage 11 - export { realApiClient, mockApiClient };
+31 -46
src/lib/apiClient/mockApiClient.ts src/lib/api/adapters/MockApiAdapter.ts
··· 1 + import type { IApiClient } from "../IApiClient"; 1 2 import type { 2 3 AtprotoSession, 3 4 BatchSearchResult, 4 5 BatchFollowResult, 5 - SearchResult, 6 6 SaveResultsResponse, 7 - } from "../../types"; 7 + SearchResult, 8 + } from "../../../types"; 8 9 9 - // Mock user data for testing 10 10 const MOCK_SESSION: AtprotoSession = { 11 11 did: "did:plc:mock123", 12 12 handle: "developer.bsky.social", ··· 15 15 description: "Testing ATlast locally", 16 16 }; 17 17 18 - // Generate mock Bluesky matches 19 18 function generateMockMatches(username: string): any[] { 20 19 const numMatches = 21 20 Math.random() < 0.7 ? Math.floor(Math.random() * 3) + 1 : 0; ··· 30 29 postCount: Math.floor(Math.random() * 1000), 31 30 followerCount: Math.floor(Math.random() * 5000), 32 31 followStatus: { 33 - "app.bsky.graph.follow": Math.random() < 0.3, // 30% already following 32 + "app.bsky.graph.follow": Math.random() < 0.3, 34 33 }, 35 34 })); 36 35 } 37 36 38 - // Simulate network delay 39 37 const delay = (ms: number = 500) => 40 38 new Promise((resolve) => setTimeout(resolve, ms)); 41 39 42 - export const mockApiClient = { 40 + /** 41 + * Mock API Client Adapter 42 + * Simulates API responses for local development 43 + */ 44 + export class MockApiAdapter implements IApiClient { 43 45 async startOAuth(handle: string): Promise<{ url: string }> { 44 46 await delay(300); 45 - console.log("[MOCK] Starting OAuth for:", handle); 46 - // In mock mode, just return to home immediately 47 47 return { url: window.location.origin + "/?session=mock" }; 48 - }, 48 + } 49 49 50 50 async getSession(): Promise<AtprotoSession> { 51 51 await delay(200); 52 - console.log("[MOCK] Getting session"); 53 52 54 - // Check if user has "logged in" via mock OAuth 55 53 const params = new URLSearchParams(window.location.search); 56 54 if (params.get("session") === "mock") { 57 55 return MOCK_SESSION; 58 56 } 59 57 60 - // Check localStorage for mock session 61 58 const mockSession = localStorage.getItem("mock_session"); 62 59 if (mockSession) { 63 60 return JSON.parse(mockSession); 64 61 } 65 62 66 63 throw new Error("No mock session"); 67 - }, 64 + } 68 65 69 66 async logout(): Promise<void> { 70 67 await delay(200); 71 - console.log("[MOCK] Logging out"); 72 68 localStorage.removeItem("mock_session"); 73 69 localStorage.removeItem("mock_uploads"); 74 - }, 70 + } 75 71 76 72 async checkFollowStatus( 77 73 dids: string[], 78 74 followLexicon: string, 79 75 ): Promise<Record<string, boolean>> { 80 76 await delay(300); 81 - console.log("[MOCK] Checking follow status for:", dids.length, "DIDs"); 82 77 83 - // Mock: 30% chance each user is already followed 84 78 const followStatus: Record<string, boolean> = {}; 85 79 dids.forEach((did) => { 86 80 followStatus[did] = Math.random() < 0.3; 87 81 }); 88 82 89 83 return followStatus; 90 - }, 84 + } 91 85 92 86 async getUploads(): Promise<{ uploads: any[] }> { 93 87 await delay(300); 94 - console.log("[MOCK] Getting uploads"); 95 88 96 89 const mockUploads = localStorage.getItem("mock_uploads"); 97 90 if (mockUploads) { ··· 99 92 } 100 93 101 94 return { uploads: [] }; 102 - }, 95 + } 103 96 104 97 async getUploadDetails( 105 98 uploadId: string, 106 99 page: number = 1, 107 100 pageSize: number = 50, 108 - ): Promise<{ 109 - results: SearchResult[]; 110 - pagination?: any; 111 - }> { 101 + ): Promise<{ results: SearchResult[]; pagination?: any }> { 112 102 await delay(500); 113 - console.log("[MOCK] Getting upload details:", uploadId); 114 103 115 104 const mockData = localStorage.getItem(`mock_upload_${uploadId}`); 116 105 if (mockData) { ··· 119 108 } 120 109 121 110 return { results: [] }; 122 - }, 111 + } 123 112 124 113 async getAllUploadDetails( 125 114 uploadId: string, 126 115 ): Promise<{ results: SearchResult[] }> { 127 116 return this.getUploadDetails(uploadId); 128 - }, 117 + } 129 118 130 119 async batchSearchActors( 131 120 usernames: string[], 132 121 followLexicon?: string, 133 122 ): Promise<{ results: BatchSearchResult[] }> { 134 - await delay(800); // Simulate API delay 135 - console.log("[MOCK] Searching for:", usernames); 123 + await delay(800); 136 124 137 125 const results: BatchSearchResult[] = usernames.map((username) => ({ 138 126 username, ··· 141 129 })); 142 130 143 131 return { results }; 144 - }, 132 + } 145 133 146 - async batchFollowUsers(dids: string[]): Promise<{ 134 + async batchFollowUsers( 135 + dids: string[], 136 + followLexicon: string, 137 + ): Promise<{ 147 138 success: boolean; 148 139 total: number; 149 140 succeeded: number; 150 141 failed: number; 142 + alreadyFollowing: number; 151 143 results: BatchFollowResult[]; 152 144 }> { 153 145 await delay(1000); 154 - console.log("[MOCK] Following users:", dids); 155 146 156 147 const results: BatchFollowResult[] = dids.map((did) => ({ 157 148 did, ··· 164 155 total: dids.length, 165 156 succeeded: dids.length, 166 157 failed: 0, 158 + alreadyFollowing: 0, 167 159 results, 168 160 }; 169 - }, 161 + } 170 162 171 163 async saveResults( 172 164 uploadId: string, 173 165 sourcePlatform: string, 174 166 results: SearchResult[], 175 - ): Promise<SaveResultsResponse> { 167 + ): Promise<SaveResultsResponse | null> { 176 168 await delay(500); 177 - console.log("[MOCK] Saving results:", { 178 - uploadId, 179 - sourcePlatform, 180 - count: results.length, 181 - }); 182 169 183 - // Save to localStorage 184 170 localStorage.setItem(`mock_upload_${uploadId}`, JSON.stringify(results)); 185 171 186 - // Add to uploads list 187 172 const uploads = JSON.parse(localStorage.getItem("mock_uploads") || "[]"); 188 173 const matchedUsers = results.filter( 189 174 (r) => r.atprotoMatches.length > 0, ··· 207 192 matchedUsers, 208 193 unmatchedUsers: results.length - matchedUsers, 209 194 }; 210 - }, 195 + } 211 196 212 - cache: { 197 + cache = { 213 198 clear: () => console.log("[MOCK] Cache cleared"), 214 199 invalidate: (key: string) => console.log("[MOCK] Cache invalidated:", key), 215 200 invalidatePattern: (pattern: string) => 216 201 console.log("[MOCK] Cache pattern invalidated:", pattern), 217 - }, 218 - }; 202 + }; 203 + }
-422
src/lib/apiClient/realApiClient.ts
··· 1 - import type { 2 - AtprotoSession, 3 - BatchSearchResult, 4 - BatchFollowResult, 5 - SaveResultsResponse, 6 - SearchResult, 7 - } from "../../types"; 8 - 9 - // Client-side cache with TTL 10 - interface CacheEntry<T> { 11 - data: T; 12 - timestamp: number; 13 - } 14 - 15 - class ResponseCache { 16 - private cache = new Map<string, CacheEntry<any>>(); 17 - private readonly defaultTTL = 5 * 60 * 1000; // 5 minutes 18 - 19 - set<T>(key: string, data: T, ttl: number = this.defaultTTL): void { 20 - this.cache.set(key, { 21 - data, 22 - timestamp: Date.now(), 23 - }); 24 - 25 - // Clean up old entries periodically 26 - if (this.cache.size > 50) { 27 - this.cleanup(); 28 - } 29 - } 30 - 31 - get<T>(key: string, ttl: number = this.defaultTTL): T | null { 32 - const entry = this.cache.get(key); 33 - if (!entry) return null; 34 - 35 - if (Date.now() - entry.timestamp > ttl) { 36 - this.cache.delete(key); 37 - return null; 38 - } 39 - 40 - return entry.data as T; 41 - } 42 - 43 - invalidate(key: string): void { 44 - this.cache.delete(key); 45 - } 46 - 47 - invalidatePattern(pattern: string): void { 48 - for (const key of this.cache.keys()) { 49 - if (key.includes(pattern)) { 50 - this.cache.delete(key); 51 - } 52 - } 53 - } 54 - 55 - clear(): void { 56 - this.cache.clear(); 57 - } 58 - 59 - private cleanup(): void { 60 - const now = Date.now(); 61 - for (const [key, entry] of this.cache.entries()) { 62 - if (now - entry.timestamp > this.defaultTTL) { 63 - this.cache.delete(key); 64 - } 65 - } 66 - } 67 - } 68 - 69 - const cache = new ResponseCache(); 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 - 83 - export const apiClient = { 84 - // OAuth and Authentication 85 - async startOAuth(handle: string): Promise<{ url: string }> { 86 - const currentOrigin = window.location.origin; 87 - 88 - const res = await fetch("/.netlify/functions/oauth-start", { 89 - method: "POST", 90 - headers: { "Content-Type": "application/json" }, 91 - body: JSON.stringify({ 92 - login_hint: handle, 93 - origin: currentOrigin, 94 - }), 95 - }); 96 - 97 - if (!res.ok) { 98 - const errorData = await res.json(); 99 - throw new Error(errorData.error || "Failed to start OAuth flow"); 100 - } 101 - 102 - const response = await res.json(); 103 - return unwrapResponse<{ url: string }>(response); 104 - }, 105 - 106 - async getSession(): Promise<{ 107 - did: string; 108 - handle: string; 109 - displayName?: string; 110 - avatar?: string; 111 - description?: string; 112 - }> { 113 - // Check cache first 114 - const cacheKey = "session"; 115 - const cached = cache.get<AtprotoSession>(cacheKey); 116 - if (cached) { 117 - console.log("Returning cached session"); 118 - return cached; 119 - } 120 - 121 - const res = await fetch("/.netlify/functions/session", { 122 - credentials: "include", 123 - }); 124 - 125 - if (!res.ok) { 126 - throw new Error("No valid session"); 127 - } 128 - 129 - const response = await res.json(); 130 - const data = unwrapResponse<AtprotoSession>(response); 131 - 132 - // Cache the session data for 5 minutes 133 - cache.set(cacheKey, data, 5 * 60 * 1000); 134 - 135 - return data; 136 - }, 137 - 138 - async logout(): Promise<void> { 139 - const res = await fetch("/.netlify/functions/logout", { 140 - method: "POST", 141 - credentials: "include", 142 - }); 143 - 144 - if (!res.ok) { 145 - throw new Error("Logout failed"); 146 - } 147 - 148 - // Clear all caches on logout 149 - cache.clear(); 150 - }, 151 - 152 - // Upload History Operations 153 - async getUploads(): Promise<{ 154 - uploads: Array<{ 155 - uploadId: string; 156 - sourcePlatform: string; 157 - createdAt: string; 158 - totalUsers: number; 159 - matchedUsers: number; 160 - unmatchedUsers: number; 161 - }>; 162 - }> { 163 - // Check cache first 164 - const cacheKey = "uploads"; 165 - const cached = cache.get<any>(cacheKey, 2 * 60 * 1000); // 2 minute cache for uploads list 166 - if (cached) { 167 - console.log("Returning cached uploads"); 168 - return cached; 169 - } 170 - 171 - const res = await fetch("/.netlify/functions/get-uploads", { 172 - credentials: "include", 173 - }); 174 - 175 - if (!res.ok) { 176 - throw new Error("Failed to fetch uploads"); 177 - } 178 - 179 - const response = await res.json(); 180 - const data = unwrapResponse<any>(response); 181 - 182 - // Cache uploads list for 2 minutes 183 - cache.set(cacheKey, data, 2 * 60 * 1000); 184 - 185 - return data; 186 - }, 187 - 188 - async getUploadDetails( 189 - uploadId: string, 190 - page: number = 1, 191 - pageSize: number = 50, 192 - ): Promise<{ 193 - results: SearchResult[]; 194 - pagination?: { 195 - page: number; 196 - pageSize: number; 197 - totalPages: number; 198 - totalUsers: number; 199 - hasNextPage: boolean; 200 - hasPrevPage: boolean; 201 - }; 202 - }> { 203 - // Check cache first (cache by page) 204 - const cacheKey = `upload-details-${uploadId}-p${page}-s${pageSize}`; 205 - const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 206 - if (cached) { 207 - console.log( 208 - "Returning cached upload details for", 209 - uploadId, 210 - "page", 211 - page, 212 - ); 213 - return cached; 214 - } 215 - 216 - const res = await fetch( 217 - `/.netlify/functions/get-upload-details?uploadId=${uploadId}&page=${page}&pageSize=${pageSize}`, 218 - { credentials: "include" }, 219 - ); 220 - 221 - if (!res.ok) { 222 - throw new Error("Failed to fetch upload details"); 223 - } 224 - 225 - const response = await res.json(); 226 - const data = unwrapResponse<any>(response); 227 - 228 - // Cache upload details page for 10 minutes 229 - cache.set(cacheKey, data, 10 * 60 * 1000); 230 - 231 - return data; 232 - }, 233 - 234 - // Helper to load all pages (for backwards compatibility) 235 - async getAllUploadDetails( 236 - uploadId: string, 237 - ): Promise<{ results: SearchResult[] }> { 238 - const firstPage = await this.getUploadDetails(uploadId, 1, 100); 239 - 240 - if (!firstPage.pagination || firstPage.pagination.totalPages === 1) { 241 - return { results: firstPage.results }; 242 - } 243 - 244 - // Load remaining pages 245 - const allResults = [...firstPage.results]; 246 - const promises = []; 247 - 248 - for (let page = 2; page <= firstPage.pagination.totalPages; page++) { 249 - promises.push(this.getUploadDetails(uploadId, page, 100)); 250 - } 251 - 252 - const remainingPages = await Promise.all(promises); 253 - for (const pageData of remainingPages) { 254 - allResults.push(...pageData.results); 255 - } 256 - 257 - return { results: allResults }; 258 - }, 259 - 260 - // NEW: Check follow status 261 - async checkFollowStatus( 262 - dids: string[], 263 - followLexicon: string, 264 - ): Promise<Record<string, boolean>> { 265 - // Check cache first 266 - const cacheKey = `follow-status-${followLexicon}-${dids.slice().sort().join(",")}`; 267 - const cached = cache.get<Record<string, boolean>>(cacheKey, 2 * 60 * 1000); // 2 minute cache 268 - if (cached) { 269 - console.log("Returning cached follow status"); 270 - return cached; 271 - } 272 - 273 - const res = await fetch("/.netlify/functions/check-follow-status", { 274 - method: "POST", 275 - credentials: "include", 276 - headers: { "Content-Type": "application/json" }, 277 - body: JSON.stringify({ dids, followLexicon }), 278 - }); 279 - 280 - if (!res.ok) { 281 - throw new Error("Failed to check follow status"); 282 - } 283 - 284 - const response = await res.json(); 285 - const data = unwrapResponse<{ followStatus: Record<string, boolean> }>( 286 - response, 287 - ); 288 - 289 - // Cache for 2 minutes 290 - cache.set(cacheKey, data.followStatus, 2 * 60 * 1000); 291 - 292 - return data.followStatus; 293 - }, 294 - 295 - // Search Operations 296 - async batchSearchActors( 297 - usernames: string[], 298 - followLexicon?: string, 299 - ): Promise<{ results: BatchSearchResult[] }> { 300 - // Create cache key from sorted usernames (so order doesn't matter) 301 - const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`; 302 - const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 303 - if (cached) { 304 - console.log( 305 - "Returning cached search results for", 306 - usernames.length, 307 - "users", 308 - ); 309 - return cached; 310 - } 311 - 312 - const res = await fetch("/.netlify/functions/batch-search-actors", { 313 - method: "POST", 314 - credentials: "include", 315 - headers: { "Content-Type": "application/json" }, 316 - body: JSON.stringify({ usernames, followLexicon }), 317 - }); 318 - 319 - if (!res.ok) { 320 - throw new Error(`Batch search failed: ${res.status}`); 321 - } 322 - 323 - const response = await res.json(); 324 - const data = unwrapResponse<{ results: BatchSearchResult[] }>(response); 325 - 326 - // Cache search results for 10 minutes 327 - cache.set(cacheKey, data, 10 * 60 * 1000); 328 - 329 - return data; 330 - }, 331 - 332 - // Follow Operations 333 - async batchFollowUsers( 334 - dids: string[], 335 - followLexicon: string, 336 - ): Promise<{ 337 - success: boolean; 338 - total: number; 339 - succeeded: number; 340 - failed: number; 341 - alreadyFollowing: number; 342 - results: BatchFollowResult[]; 343 - }> { 344 - const res = await fetch("/.netlify/functions/batch-follow-users", { 345 - method: "POST", 346 - credentials: "include", 347 - headers: { "Content-Type": "application/json" }, 348 - body: JSON.stringify({ dids, followLexicon }), 349 - }); 350 - 351 - if (!res.ok) { 352 - throw new Error("Batch follow failed"); 353 - } 354 - 355 - const response = await res.json(); 356 - const data = unwrapResponse<any>(response); 357 - 358 - // Invalidate caches after following 359 - cache.invalidate("uploads"); 360 - cache.invalidatePattern("upload-details"); 361 - cache.invalidatePattern("follow-status"); 362 - 363 - return data; 364 - }, 365 - 366 - // Save Results 367 - async saveResults( 368 - uploadId: string, 369 - sourcePlatform: string, 370 - results: SearchResult[], 371 - ): Promise<SaveResultsResponse | null> { 372 - try { 373 - const resultsToSave = results 374 - .filter((r) => !r.isSearching) 375 - .map((r) => ({ 376 - sourceUser: r.sourceUser, 377 - atprotoMatches: r.atprotoMatches || [], 378 - })); 379 - 380 - console.log(`Saving ${resultsToSave.length} results in background...`); 381 - 382 - const res = await fetch("/.netlify/functions/save-results", { 383 - method: "POST", 384 - credentials: "include", 385 - headers: { "Content-Type": "application/json" }, 386 - body: JSON.stringify({ 387 - uploadId, 388 - sourcePlatform, 389 - results: resultsToSave, 390 - }), 391 - }); 392 - 393 - if (res.ok) { 394 - const response = await res.json(); 395 - const data = unwrapResponse<SaveResultsResponse>(response); 396 - console.log(`Successfully saved ${data.matchedUsers} matches`); 397 - 398 - // Invalidate caches after saving 399 - cache.invalidate("uploads"); 400 - cache.invalidatePattern("upload-details"); 401 - 402 - return data; 403 - } else { 404 - console.error("Failed to save results:", res.status, await res.text()); 405 - return null; 406 - } 407 - } catch (error) { 408 - console.error( 409 - "Error saving results (will continue in background):", 410 - error, 411 - ); 412 - return null; 413 - } 414 - }, 415 - 416 - // Cache management utilities 417 - cache: { 418 - clear: () => cache.clear(), 419 - invalidate: (key: string) => cache.invalidate(key), 420 - invalidatePattern: (pattern: string) => cache.invalidatePattern(pattern), 421 - }, 422 - };
-19
src/lib/config.ts
··· 1 - export const ENV = { 2 - // Detect if we're in local mock mode 3 - IS_LOCAL_MOCK: import.meta.env.VITE_LOCAL_MOCK === "true", 4 - 5 - // API base URL 6 - API_BASE: import.meta.env.VITE_API_BASE || "/.netlify/functions", 7 - 8 - // Feature flags 9 - ENABLE_OAUTH: import.meta.env.VITE_ENABLE_OAUTH !== "false", 10 - ENABLE_DATABASE: import.meta.env.VITE_ENABLE_DATABASE !== "false", 11 - } as const; 12 - 13 - export function isLocalMockMode(): boolean { 14 - return ENV.IS_LOCAL_MOCK; 15 - } 16 - 17 - export function getApiUrl(endpoint: string): string { 18 - return `${ENV.API_BASE}/${endpoint}`; 19 - }
src/lib/fileExtractor.ts src/lib/parsers/fileExtractor.ts
src/lib/parserLogic.ts src/lib/parsers/parserLogic.ts
src/lib/platformDefinitions.ts src/lib/parsers/platformDefinitions.ts
+65
src/lib/utils/cache.ts
··· 1 + /** 2 + * Generic client-side cache with TTL support 3 + * Used for simple caching needs on the frontend 4 + **/ 5 + export class CacheService<T = any> { 6 + private cache = new Map<string, { value: T; expires: number }>(); 7 + private readonly defaultTTL: number; 8 + 9 + constructor(defaultTTLMs: number = 5 * 60 * 1000) { 10 + this.defaultTTL = defaultTTLMs; 11 + } 12 + 13 + set(key: string, value: T, ttlMs?: number): void { 14 + const ttl = ttlMs ?? this.defaultTTL; 15 + this.cache.set(key, { 16 + value, 17 + expires: Date.now() + ttl, 18 + }); 19 + 20 + if (this.cache.size > 100) { 21 + this.cleanup(); 22 + } 23 + } 24 + 25 + get<U = T>(key: string): U | null { 26 + const entry = this.cache.get(key); 27 + if (!entry) return null; 28 + 29 + if (Date.now() > entry.expires) { 30 + this.cache.delete(key); 31 + return null; 32 + } 33 + 34 + return entry.value as U; 35 + } 36 + 37 + has(key: string): boolean { 38 + return this.get(key) !== null; 39 + } 40 + 41 + delete(key: string): void { 42 + this.cache.delete(key); 43 + } 44 + 45 + invalidatePattern(pattern: string): void { 46 + for (const key of this.cache.keys()) { 47 + if (key.includes(pattern)) { 48 + this.cache.delete(key); 49 + } 50 + } 51 + } 52 + 53 + clear(): void { 54 + this.cache.clear(); 55 + } 56 + 57 + cleanup(): void { 58 + const now = Date.now(); 59 + for (const [key, entry] of this.cache.entries()) { 60 + if (now > entry.expires) { 61 + this.cache.delete(key); 62 + } 63 + } 64 + } 65 + }
+26
src/lib/utils/date.ts
··· 1 + export function formatDate(dateString: string): string { 2 + const date = new Date(dateString); 3 + return date.toLocaleDateString("en-US", { 4 + month: "short", 5 + day: "numeric", 6 + year: "numeric", 7 + hour: "2-digit", 8 + minute: "2-digit", 9 + }); 10 + } 11 + 12 + export function formatRelativeTime(dateString: string): string { 13 + const date = new Date(dateString); 14 + const now = new Date(); 15 + const diffMs = now.getTime() - date.getTime(); 16 + const diffMins = Math.floor(diffMs / 60000); 17 + const diffHours = Math.floor(diffMs / 3600000); 18 + const diffDays = Math.floor(diffMs / 86400000); 19 + 20 + if (diffMins < 1) return "just now"; 21 + if (diffMins < 60) return `${diffMins}m ago`; 22 + if (diffHours < 24) return `${diffHours}h ago`; 23 + if (diffDays < 7) return `${diffDays}d ago`; 24 + 25 + return formatDate(dateString); 26 + }
+2 -2
src/lib/utils/platform.ts
··· 1 - import { PLATFORMS, type PlatformConfig } from "../../constants/platforms"; 2 - import { ATPROTO_APPS, type AtprotoApp } from "../../constants/atprotoApps"; 1 + import { PLATFORMS, type PlatformConfig } from "../../config/platforms"; 2 + import { ATPROTO_APPS, type AtprotoApp } from "../../config/atprotoApps"; 3 3 import type { AtprotoAppId } from "../../types/settings"; 4 4 5 5 /**
+1 -1
src/pages/Home.tsx
··· 6 6 import UploadTab from "../components/UploadTab"; 7 7 import HistoryTab from "../components/HistoryTab"; 8 8 import PlaceholderTab from "../components/PlaceholderTab"; 9 - import { apiClient } from "../lib/apiClient"; 9 + import { apiClient } from "../lib/api/client"; 10 10 import type { Upload as UploadType } from "../types"; 11 11 import type { UserSettings } from "../types/settings"; 12 12 import SettingsPage from "./Settings";
+2 -2
src/pages/Settings.tsx
··· 1 1 import { Settings as SettingsIcon, ChevronRight } from "lucide-react"; 2 - import { PLATFORMS } from "../constants/platforms"; 3 - import { ATPROTO_APPS } from "../constants/atprotoApps"; 2 + import { PLATFORMS } from "../config/platforms"; 3 + import { ATPROTO_APPS } from "../config/atprotoApps"; 4 4 import type { UserSettings, PlatformDestinations } from "../types/settings"; 5 5 6 6 interface SettingsPageProps {
-2
src/types/auth.types.ts
··· 1 - // Authentication and session types 2 - 3 1 export interface AtprotoSession { 4 2 did: string; 5 3 handle: string;
-2
src/types/common.types.ts
··· 1 - // Common shared types 2 - 3 1 export type AppStep = 4 2 | "checking" 5 3 | "login"
-5
src/types/index.ts
··· 1 - // Session and Auth Types 2 1 export * from "./auth.types"; 3 - 4 - // Search and Match Types 5 2 export * from "./search.types"; 6 - 7 - // Common Types 8 3 export * from "./common.types"; 9 4 10 5 // Re-export settings types for convenience