+17
-51
netlify/functions/batch-follow-users.ts
+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
+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
+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
+11
netlify/functions/core/errors/ApiError.ts
+9
netlify/functions/core/errors/AuthenticationError.ts
+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
+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
+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
+9
netlify/functions/core/errors/ValidationError.ts
+17
netlify/functions/core/errors/index.ts
+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
+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
+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
+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
+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
+2
netlify/functions/infrastructure/database/index.ts
+2
netlify/functions/infrastructure/oauth/index.ts
+2
netlify/functions/infrastructure/oauth/index.ts
+4
-4
netlify/functions/init-db.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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",
-2
netlify/functions/shared/constants/index.ts
netlify/functions/core/config/constants.ts
-2
netlify/functions/shared/constants/index.ts
netlify/functions/core/config/constants.ts
+2
-2
netlify/functions/shared/middleware/auth.middleware.ts
netlify/functions/core/middleware/auth.middleware.ts
+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
+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
netlify/functions/shared/middleware/index.ts
netlify/functions/core/middleware/index.ts
+63
-47
netlify/functions/shared/repositories/MatchRepository.ts
netlify/functions/repositories/MatchRepository.ts
+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
+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
netlify/functions/shared/repositories/UploadRepository.ts
netlify/functions/repositories/UploadRepository.ts
-1
netlify/functions/shared/repositories/index.ts
netlify/functions/repositories/index.ts
-1
netlify/functions/shared/repositories/index.ts
netlify/functions/repositories/index.ts
+4
-13
netlify/functions/shared/services/database/DatabaseService.ts
netlify/functions/infrastructure/database/DatabaseService.ts
+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
+7
-8
netlify/functions/shared/services/oauth/client.factory.ts
netlify/functions/infrastructure/oauth/OAuthClientFactory.ts
+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
+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;
+3
-3
netlify/functions/shared/services/session/stores/SessionStore.ts
netlify/functions/infrastructure/oauth/stores/SessionStore.ts
+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
+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
+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/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
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
+6
netlify/functions/shared/types/database.types.ts
netlify/functions/core/types/database.types.ts
netlify/functions/shared/types/index.ts
netlify/functions/core/types/index.ts
netlify/functions/shared/types/index.ts
netlify/functions/core/types/index.ts
-1
netlify/functions/shared/utils/index.ts
netlify/functions/utils/index.ts
-1
netlify/functions/shared/utils/index.ts
netlify/functions/utils/index.ts
+1
-1
netlify/functions/shared/utils/response.utils.ts
netlify/functions/utils/response.utils.ts
+1
-1
netlify/functions/shared/utils/response.utils.ts
netlify/functions/utils/response.utils.ts
netlify/functions/shared/utils/string.utils.ts
netlify/functions/utils/string.utils.ts
netlify/functions/shared/utils/string.utils.ts
netlify/functions/utils/string.utils.ts
+2
-2
src/App.tsx
+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
+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
-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
+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
-2
src/components/PlatformSelector.tsx
+7
-14
src/components/SearchResultCard.tsx
+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
+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
+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
+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
+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
src/constants/atprotoApps.ts
src/config/atprotoApps.ts
-21
src/constants/platforms.ts
src/config/platforms.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
+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
-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
+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
+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
+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
+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
+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
-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
+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
-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
-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/fileExtractor.ts
src/lib/parsers/fileExtractor.ts
src/lib/parserLogic.ts
src/lib/parsers/parserLogic.ts
src/lib/parserLogic.ts
src/lib/parsers/parserLogic.ts
src/lib/platformDefinitions.ts
src/lib/parsers/platformDefinitions.ts
src/lib/platformDefinitions.ts
src/lib/parsers/platformDefinitions.ts
+65
src/lib/utils/cache.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
+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
+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
+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
+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
-2
src/types/auth.types.ts
-2
src/types/common.types.ts
-2
src/types/common.types.ts