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

more refactoring oops

authored by byarielm.fyi and committed by byarielm.fyi 95a28455 92f9973b

verified
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