···11-import { neon, NeonQueryFunction } from "@neondatabase/serverless";
22-33-let sql: NeonQueryFunction<any, any> | undefined = undefined;
44-let connectionInitialized = false;
55-66-export function getDbClient() {
77- if (!sql) {
88- sql = neon(process.env.NETLIFY_DATABASE_URL!);
99- connectionInitialized = true;
1010- }
1111- return sql;
1212-}
1313-1414-export async function initDB() {
1515- const sql = getDbClient();
1616-1717- console.log("🧠 Connecting to DB:", process.env.NETLIFY_DATABASE_URL);
1818-1919- try {
2020- const res: any =
2121- await sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`;
2222- console.log("✅ Connected:", res[0]);
2323- } catch (e) {
2424- console.error("❌ Connection failed:", e);
2525- throw e;
2626- }
2727-2828- // OAuth Tables
2929- await sql`
3030- CREATE TABLE IF NOT EXISTS oauth_states (
3131- key TEXT PRIMARY KEY,
3232- data JSONB NOT NULL,
3333- created_at TIMESTAMP DEFAULT NOW(),
3434- expires_at TIMESTAMP NOT NULL
3535- )
3636- `;
3737-3838- await sql`
3939- CREATE TABLE IF NOT EXISTS oauth_sessions (
4040- key TEXT PRIMARY KEY,
4141- data JSONB NOT NULL,
4242- created_at TIMESTAMP DEFAULT NOW(),
4343- expires_at TIMESTAMP NOT NULL
4444- )
4545- `;
4646-4747- await sql`
4848- CREATE TABLE IF NOT EXISTS user_sessions (
4949- session_id TEXT PRIMARY KEY,
5050- did TEXT NOT NULL,
5151- created_at TIMESTAMP DEFAULT NOW(),
5252- expires_at TIMESTAMP NOT NULL
5353- )
5454- `;
5555-5656- // User + Match Tracking
5757- await sql`
5858- CREATE TABLE IF NOT EXISTS user_uploads (
5959- upload_id TEXT PRIMARY KEY,
6060- did TEXT NOT NULL,
6161- source_platform TEXT NOT NULL,
6262- created_at TIMESTAMP DEFAULT NOW(),
6363- last_checked TIMESTAMP,
6464- total_users INTEGER NOT NULL,
6565- matched_users INTEGER DEFAULT 0,
6666- unmatched_users INTEGER DEFAULT 0
6767- )
6868- `;
6969-7070- await sql`
7171- CREATE TABLE IF NOT EXISTS source_accounts (
7272- id SERIAL PRIMARY KEY,
7373- source_platform TEXT NOT NULL,
7474- source_username TEXT NOT NULL,
7575- normalized_username TEXT NOT NULL,
7676- last_checked TIMESTAMP,
7777- match_found BOOLEAN DEFAULT FALSE,
7878- match_found_at TIMESTAMP,
7979- created_at TIMESTAMP DEFAULT NOW(),
8080- UNIQUE(source_platform, normalized_username)
8181- )
8282- `;
8383-8484- await sql`
8585- CREATE TABLE IF NOT EXISTS user_source_follows (
8686- id SERIAL PRIMARY KEY,
8787- upload_id TEXT NOT NULL REFERENCES user_uploads(upload_id) ON DELETE CASCADE,
8888- did TEXT NOT NULL,
8989- source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE,
9090- source_date TEXT,
9191- created_at TIMESTAMP DEFAULT NOW(),
9292- UNIQUE(upload_id, source_account_id)
9393- )
9494- `;
9595-9696- await sql`
9797- CREATE TABLE IF NOT EXISTS atproto_matches (
9898- id SERIAL PRIMARY KEY,
9999- source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE,
100100- atproto_did TEXT NOT NULL,
101101- atproto_handle TEXT NOT NULL,
102102- atproto_display_name TEXT,
103103- atproto_avatar TEXT,
104104- atproto_description TEXT,
105105- post_count INTEGER,
106106- follower_count INTEGER,
107107- match_score INTEGER NOT NULL,
108108- found_at TIMESTAMP DEFAULT NOW(),
109109- last_verified TIMESTAMP,
110110- is_active BOOLEAN DEFAULT TRUE,
111111- follow_status JSONB DEFAULT '{}',
112112- last_follow_check TIMESTAMP,
113113- UNIQUE(source_account_id, atproto_did)
114114- )
115115- `;
116116-117117- await sql`
118118- CREATE TABLE IF NOT EXISTS user_match_status (
119119- id SERIAL PRIMARY KEY,
120120- did TEXT NOT NULL,
121121- atproto_match_id INTEGER NOT NULL REFERENCES atproto_matches(id) ON DELETE CASCADE,
122122- source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE,
123123- notified BOOLEAN DEFAULT FALSE,
124124- notified_at TIMESTAMP,
125125- viewed BOOLEAN DEFAULT FALSE,
126126- viewed_at TIMESTAMP,
127127- followed BOOLEAN DEFAULT FALSE,
128128- followed_at TIMESTAMP,
129129- dismissed BOOLEAN DEFAULT FALSE,
130130- dismissed_at TIMESTAMP,
131131- UNIQUE(did, atproto_match_id)
132132- )
133133- `;
134134-135135- await sql`
136136- CREATE TABLE IF NOT EXISTS notification_queue (
137137- id SERIAL PRIMARY KEY,
138138- did TEXT NOT NULL,
139139- new_matches_count INTEGER NOT NULL,
140140- created_at TIMESTAMP DEFAULT NOW(),
141141- sent BOOLEAN DEFAULT FALSE,
142142- sent_at TIMESTAMP,
143143- retry_count INTEGER DEFAULT 0,
144144- last_error TEXT
145145- )
146146- `;
147147-148148- // Existing indexes
149149- await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`;
150150- await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`;
151151- await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_did ON user_source_follows(did)`;
152152- await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_source ON user_source_follows(source_account_id)`;
153153- await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source ON atproto_matches(source_account_id)`;
154154- await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_did ON atproto_matches(atproto_did)`;
155155- await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_notified ON user_match_status(did, notified, viewed)`;
156156- await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`;
157157- await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`;
158158-159159- // ======== Enhanced indexes for common query patterns =========
160160-161161- // For sorting
162162- await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_stats ON atproto_matches(source_account_id, found_at DESC, post_count DESC, follower_count DESC)`;
163163-164164- // For session lookups (most frequent query)
165165- await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`;
166166- await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)`;
167167-168168- // For OAuth state/session cleanup
169169- await sql`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)`;
170170- await sql`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at)`;
171171-172172- // For upload queries by user
173173- await sql`CREATE INDEX IF NOT EXISTS idx_user_uploads_did_created ON user_uploads(did, created_at DESC)`;
174174-175175- // For upload details pagination (composite index for ORDER BY + JOIN)
176176- await sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_upload_created ON user_source_follows(upload_id, source_account_id)`;
177177-178178- // For match status queries
179179- await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_match_id ON user_match_status(atproto_match_id)`;
180180-181181- // Composite index for the common join pattern in get-upload-details
182182- await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source_active ON atproto_matches(source_account_id, is_active) WHERE is_active = true`;
183183-184184- // For bulk operations - normalized username lookups
185185- await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`;
186186-187187- // Follow status indexes
188188- await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_status ON atproto_matches USING gin(follow_status)`;
189189- await sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_check ON atproto_matches(last_follow_check)`;
190190-191191- console.log("✅ Database indexes created/verified");
192192-}
193193-194194-export async function cleanupExpiredSessions() {
195195- const sql = getDbClient();
196196-197197- // Use indexes for efficient cleanup
198198- const statesDeleted =
199199- await sql`DELETE FROM oauth_states WHERE expires_at < NOW()`;
200200- const sessionsDeleted =
201201- await sql`DELETE FROM oauth_sessions WHERE expires_at < NOW()`;
202202- const userSessionsDeleted =
203203- await sql`DELETE FROM user_sessions WHERE expires_at < NOW()`;
204204-205205- console.log("🧹 Cleanup:", {
206206- states: (statesDeleted as any).length,
207207- sessions: (sessionsDeleted as any).length,
208208- userSessions: (userSessionsDeleted as any).length,
209209- });
210210-}
211211-212212-export { getDbClient as sql };
+86-176
netlify/functions/get-upload-details.ts
···11-import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions";
22-import { userSessions } from "./oauth-stores-db";
33-import { getDbClient } from "./db";
44-import cookie from "cookie";
11+import { AuthenticatedHandler } from "./shared/types";
22+import { MatchRepository } from "./shared/repositories";
33+import { successResponse } from "./shared/utils";
44+import { withAuthErrorHandling } from "./shared/middleware";
55+import { ValidationError, NotFoundError } from "./shared/constants/errors";
5667const DEFAULT_PAGE_SIZE = 50;
78const MAX_PAGE_SIZE = 100;
8999-export const handler: Handler = async (
1010- event: HandlerEvent,
1111-): Promise<HandlerResponse> => {
1212- try {
1313- const uploadId = event.queryStringParameters?.uploadId;
1414- const page = parseInt(event.queryStringParameters?.page || "1");
1515- const pageSize = Math.min(
1616- parseInt(
1717- event.queryStringParameters?.pageSize || String(DEFAULT_PAGE_SIZE),
1818- ),
1919- MAX_PAGE_SIZE,
2020- );
1010+const getUploadDetailsHandler: AuthenticatedHandler = async (context) => {
1111+ const uploadId = context.event.queryStringParameters?.uploadId;
1212+ const page = parseInt(context.event.queryStringParameters?.page || "1");
1313+ const pageSize = Math.min(
1414+ parseInt(
1515+ context.event.queryStringParameters?.pageSize ||
1616+ String(DEFAULT_PAGE_SIZE),
1717+ ),
1818+ MAX_PAGE_SIZE,
1919+ );
21202222- if (!uploadId) {
2323- return {
2424- statusCode: 400,
2525- headers: { "Content-Type": "application/json" },
2626- body: JSON.stringify({ error: "uploadId is required" }),
2727- };
2828- }
2121+ if (!uploadId) {
2222+ throw new ValidationError("uploadId is required");
2323+ }
29243030- if (page < 1 || pageSize < 1) {
3131- return {
3232- statusCode: 400,
3333- headers: { "Content-Type": "application/json" },
3434- body: JSON.stringify({ error: "Invalid page or pageSize parameters" }),
3535- };
3636- }
2525+ if (page < 1 || pageSize < 1) {
2626+ throw new ValidationError("Invalid page or pageSize parameters");
2727+ }
37283838- // Get session from cookie
3939- const cookies = event.headers.cookie
4040- ? cookie.parse(event.headers.cookie)
4141- : {};
4242- const sessionId = cookies.atlast_session;
2929+ const matchRepo = new MatchRepository();
43304444- if (!sessionId) {
4545- return {
4646- statusCode: 401,
4747- headers: { "Content-Type": "application/json" },
4848- body: JSON.stringify({ error: "No session cookie" }),
4949- };
5050- }
3131+ // Fetch paginated results
3232+ const { results, totalUsers } = await matchRepo.getUploadDetails(
3333+ uploadId,
3434+ context.did,
3535+ page,
3636+ pageSize,
3737+ );
51385252- // Get DID from session
5353- const userSession = await userSessions.get(sessionId);
5454- if (!userSession) {
5555- return {
5656- statusCode: 401,
5757- headers: { "Content-Type": "application/json" },
5858- body: JSON.stringify({ error: "Invalid or expired session" }),
5959- };
6060- }
6161-6262- const sql = getDbClient();
6363-6464- // Verify upload belongs to user and get total count
6565- const uploadCheck = await sql`
6666- SELECT upload_id, total_users FROM user_uploads
6767- WHERE upload_id = ${uploadId} AND did = ${userSession.did}
6868- `;
6969-7070- if ((uploadCheck as any[]).length === 0) {
7171- return {
7272- statusCode: 404,
7373- headers: { "Content-Type": "application/json" },
7474- body: JSON.stringify({ error: "Upload not found" }),
7575- };
7676- }
3939+ if (totalUsers === 0) {
4040+ throw new NotFoundError("Upload not found");
4141+ }
77427878- const totalUsers = (uploadCheck as any[])[0].total_users;
7979- const totalPages = Math.ceil(totalUsers / pageSize);
8080- const offset = (page - 1) * pageSize;
8181-8282- // Fetch paginated results with optimized query
8383- const results = await sql`
8484- SELECT
8585- sa.source_username,
8686- sa.normalized_username,
8787- usf.source_date,
8888- am.atproto_did,
8989- am.atproto_handle,
9090- am.atproto_display_name,
9191- am.atproto_avatar,
9292- am.atproto_description,
9393- am.match_score,
9494- am.post_count,
9595- am.follower_count,
9696- am.found_at,
9797- am.follow_status,
9898- am.last_follow_check,
9999- ums.followed,
100100- ums.dismissed,
101101- -- Calculate if this is a new match (found after upload creation)
102102- CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match
103103- FROM user_source_follows usf
104104- JOIN source_accounts sa ON usf.source_account_id = sa.id
105105- JOIN user_uploads uu ON usf.upload_id = uu.upload_id
106106- LEFT JOIN atproto_matches am ON sa.id = am.source_account_id AND am.is_active = true
107107- LEFT JOIN user_match_status ums ON am.id = ums.atproto_match_id AND ums.did = ${userSession.did}
108108- WHERE usf.upload_id = ${uploadId}
109109- ORDER BY
110110- -- 1. Users with matches first
111111- CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END,
112112- -- 2. New matches (found after initial upload)
113113- is_new_match DESC,
114114- -- 3. Highest post count
115115- am.post_count DESC NULLS LAST,
116116- -- 4. Highest follower count
117117- am.follower_count DESC NULLS LAST,
118118- -- 5. Username as tiebreaker
119119- sa.source_username
120120- LIMIT ${pageSize}
121121- OFFSET ${offset}
122122- `;
4343+ const totalPages = Math.ceil(totalUsers / pageSize);
12344124124- // Group results by source username
125125- const groupedResults = new Map<string, any>();
4545+ // Group results by source username
4646+ const groupedResults = new Map<string, any>();
12647127127- (results as any[]).forEach((row: any) => {
128128- const username = row.source_username;
4848+ results.forEach((row: any) => {
4949+ const username = row.source_username;
12950130130- // Get or create the entry for this username
131131- let userResult = groupedResults.get(username);
5151+ // Get or create the entry for this username
5252+ let userResult = groupedResults.get(username);
13253133133- if (!userResult) {
134134- userResult = {
135135- sourceUser: {
136136- username: username,
137137- date: row.source_date || "",
138138- },
139139- atprotoMatches: [],
140140- };
141141- groupedResults.set(username, userResult); // Add to map, this preserves the order
142142- }
5454+ if (!userResult) {
5555+ userResult = {
5656+ sourceUser: {
5757+ username: username,
5858+ date: row.source_date || "",
5959+ },
6060+ atprotoMatches: [],
6161+ };
6262+ groupedResults.set(username, userResult);
6363+ }
14364144144- // Add the match (if it exists) to the array
145145- if (row.atproto_did) {
146146- userResult.atprotoMatches.push({
147147- did: row.atproto_did,
148148- handle: row.atproto_handle,
149149- displayName: row.atproto_display_name,
150150- avatar: row.atproto_avatar,
151151- description: row.atproto_description,
152152- matchScore: row.match_score,
153153- postCount: row.post_count,
154154- followerCount: row.follower_count,
155155- foundAt: row.found_at,
156156- followed: row.followed || false,
157157- dismissed: row.dismissed || false,
158158- followStatus: row.follow_status || {},
159159- });
160160- }
161161- });
6565+ // Add the match (if it exists) to the array
6666+ if (row.atproto_did) {
6767+ userResult.atprotoMatches.push({
6868+ did: row.atproto_did,
6969+ handle: row.atproto_handle,
7070+ displayName: row.atproto_display_name,
7171+ avatar: row.atproto_avatar,
7272+ description: row.atproto_description,
7373+ matchScore: row.match_score,
7474+ postCount: row.post_count,
7575+ followerCount: row.follower_count,
7676+ foundAt: row.found_at,
7777+ followed: row.followed || false,
7878+ dismissed: row.dismissed || false,
7979+ followStatus: row.follow_status || {},
8080+ });
8181+ }
8282+ });
16283163163- const searchResults = Array.from(groupedResults.values());
8484+ const searchResults = Array.from(groupedResults.values());
16485165165- return {
166166- statusCode: 200,
167167- headers: {
168168- "Content-Type": "application/json",
169169- "Access-Control-Allow-Origin": "*",
170170- "Cache-Control": "private, max-age=600", // 10 minute browser cache
8686+ return successResponse(
8787+ {
8888+ results: searchResults,
8989+ pagination: {
9090+ page,
9191+ pageSize,
9292+ totalPages,
9393+ totalUsers,
9494+ hasNextPage: page < totalPages,
9595+ hasPrevPage: page > 1,
17196 },
172172- body: JSON.stringify({
173173- results: searchResults,
174174- pagination: {
175175- page,
176176- pageSize,
177177- totalPages,
178178- totalUsers,
179179- hasNextPage: page < totalPages,
180180- hasPrevPage: page > 1,
181181- },
182182- }),
183183- };
184184- } catch (error) {
185185- console.error("Get upload details error:", error);
186186- return {
187187- statusCode: 500,
188188- headers: { "Content-Type": "application/json" },
189189- body: JSON.stringify({
190190- error: "Failed to fetch upload details",
191191- details: error instanceof Error ? error.message : "Unknown error",
192192- }),
193193- };
194194- }
9797+ },
9898+ 200,
9999+ {
100100+ "Cache-Control": "private, max-age=600",
101101+ },
102102+ );
195103};
104104+105105+export const handler = withAuthErrorHandling(getUploadDetailsHandler);
···11+export * from "./types";
22+export * from "./constants";
33+export * from "./utils";
44+export * from "./middleware";
55+export * from "./services/database";
66+export * from "./services/session";
77+export * from "./services/oauth";
···11+import { BaseRepository } from "./BaseRepository";
22+import { UserUploadRow } from "../types";
33+44+export class UploadRepository extends BaseRepository {
55+ /**
66+ * Create a new upload record
77+ **/
88+ async createUpload(
99+ uploadId: string,
1010+ did: string,
1111+ sourcePlatform: string,
1212+ totalUsers: number,
1313+ matchedUsers: number,
1414+ ): Promise<void> {
1515+ await this.sql`
1616+ INSERT INTO user_uploads (upload_id, did, source_platform, total_users, matched_users, unmatched_users)
1717+ VALUES (${uploadId}, ${did}, ${sourcePlatform}, ${totalUsers}, ${matchedUsers}, ${totalUsers - matchedUsers})
1818+ ON CONFLICT (upload_id) DO NOTHING
1919+ `;
2020+ }
2121+2222+ /**
2323+ * Get all uploads for a user
2424+ **/
2525+ async getUserUploads(did: string): Promise<UserUploadRow[]> {
2626+ const result = await this.sql`
2727+ SELECT
2828+ upload_id,
2929+ source_platform,
3030+ created_at,
3131+ total_users,
3232+ matched_users,
3333+ unmatched_users
3434+ FROM user_uploads
3535+ WHERE did = ${did}
3636+ ORDER BY created_at DESC
3737+ `;
3838+ return result as UserUploadRow[];
3939+ }
4040+4141+ /**
4242+ * Get a specific upload
4343+ **/
4444+ async getUpload(
4545+ uploadId: string,
4646+ did: string,
4747+ ): Promise<UserUploadRow | null> {
4848+ const result = await this.sql`
4949+ SELECT * FROM user_uploads
5050+ WHERE upload_id = ${uploadId} AND did = ${did}
5151+ `;
5252+ const rows = result as UserUploadRow[];
5353+ return rows[0] || null;
5454+ }
5555+5656+ /**
5757+ * Update upload match counts
5858+ **/
5959+ async updateMatchCounts(
6060+ uploadId: string,
6161+ matchedUsers: number,
6262+ unmatchedUsers: number,
6363+ ): Promise<void> {
6464+ await this.sql`
6565+ UPDATE user_uploads
6666+ SET matched_users = ${matchedUsers},
6767+ unmatched_users = ${unmatchedUsers}
6868+ WHERE upload_id = ${uploadId}
6969+ `;
7070+ }
7171+7272+ /**
7373+ * Check for recent uploads (within 5 seconds)
7474+ **/
7575+ async hasRecentUpload(did: string): Promise<boolean> {
7676+ const result = await this.sql`
7777+ SELECT upload_id FROM user_uploads
7878+ WHERE did = ${did}
7979+ AND created_at > NOW() - INTERVAL '5 seconds'
8080+ ORDER BY created_at DESC
8181+ LIMIT 1
8282+ `;
8383+ return (result as any[]).length > 0;
8484+ }
8585+}
+4
netlify/functions/shared/repositories/index.ts
···11+export * from "./BaseRepository";
22+export * from "./UploadRepository";
33+export * from "./SourceAccountRepository";
44+export * from "./MatchRepository";
···11+import { getDbClient } from "./connection";
22+import { DatabaseError } from "../../constants/errors";
33+44+export class DatabaseService {
55+ private sql = getDbClient();
66+77+ async initDatabase(): Promise<void> {
88+ try {
99+ console.log(
1010+ "🧠 Connecting to DB:",
1111+ process.env.NETLIFY_DATABASE_URL?.split("@")[1],
1212+ );
1313+1414+ // Test connection
1515+ const res = (await this
1616+ .sql`SELECT current_database() AS db, current_user AS user, NOW() AS now`) as Record<
1717+ string,
1818+ any
1919+ >[];
2020+ console.log("✅ Connected:", res[0]);
2121+2222+ // Create tables
2323+ await this.createTables();
2424+ await this.createIndexes();
2525+2626+ console.log("✅ Database initialized successfully");
2727+ } catch (error) {
2828+ console.error("❌ Database initialization failed:", error);
2929+ throw new DatabaseError(
3030+ "Failed to initialize database",
3131+ error instanceof Error ? error.message : "Unknown error",
3232+ );
3333+ }
3434+ }
3535+3636+ private async createTables(): Promise<void> {
3737+ // OAuth Tables
3838+ await this.sql`
3939+ CREATE TABLE IF NOT EXISTS oauth_states (
4040+ key TEXT PRIMARY KEY,
4141+ data JSONB NOT NULL,
4242+ created_at TIMESTAMP DEFAULT NOW(),
4343+ expires_at TIMESTAMP NOT NULL
4444+ )
4545+ `;
4646+4747+ await this.sql`
4848+ CREATE TABLE IF NOT EXISTS oauth_sessions (
4949+ key TEXT PRIMARY KEY,
5050+ data JSONB NOT NULL,
5151+ created_at TIMESTAMP DEFAULT NOW(),
5252+ expires_at TIMESTAMP NOT NULL
5353+ )
5454+ `;
5555+5656+ await this.sql`
5757+ CREATE TABLE IF NOT EXISTS user_sessions (
5858+ session_id TEXT PRIMARY KEY,
5959+ did TEXT NOT NULL,
6060+ created_at TIMESTAMP DEFAULT NOW(),
6161+ expires_at TIMESTAMP NOT NULL
6262+ )
6363+ `;
6464+6565+ // User + Match Tracking
6666+ await this.sql`
6767+ CREATE TABLE IF NOT EXISTS user_uploads (
6868+ upload_id TEXT PRIMARY KEY,
6969+ did TEXT NOT NULL,
7070+ source_platform TEXT NOT NULL,
7171+ created_at TIMESTAMP DEFAULT NOW(),
7272+ last_checked TIMESTAMP,
7373+ total_users INTEGER NOT NULL,
7474+ matched_users INTEGER DEFAULT 0,
7575+ unmatched_users INTEGER DEFAULT 0
7676+ )
7777+ `;
7878+7979+ await this.sql`
8080+ CREATE TABLE IF NOT EXISTS source_accounts (
8181+ id SERIAL PRIMARY KEY,
8282+ source_platform TEXT NOT NULL,
8383+ source_username TEXT NOT NULL,
8484+ normalized_username TEXT NOT NULL,
8585+ last_checked TIMESTAMP,
8686+ match_found BOOLEAN DEFAULT FALSE,
8787+ match_found_at TIMESTAMP,
8888+ created_at TIMESTAMP DEFAULT NOW(),
8989+ UNIQUE(source_platform, normalized_username)
9090+ )
9191+ `;
9292+9393+ await this.sql`
9494+ CREATE TABLE IF NOT EXISTS user_source_follows (
9595+ id SERIAL PRIMARY KEY,
9696+ upload_id TEXT NOT NULL REFERENCES user_uploads(upload_id) ON DELETE CASCADE,
9797+ did TEXT NOT NULL,
9898+ source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE,
9999+ source_date TEXT,
100100+ created_at TIMESTAMP DEFAULT NOW(),
101101+ UNIQUE(upload_id, source_account_id)
102102+ )
103103+ `;
104104+105105+ await this.sql`
106106+ CREATE TABLE IF NOT EXISTS atproto_matches (
107107+ id SERIAL PRIMARY KEY,
108108+ source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE,
109109+ atproto_did TEXT NOT NULL,
110110+ atproto_handle TEXT NOT NULL,
111111+ atproto_display_name TEXT,
112112+ atproto_avatar TEXT,
113113+ atproto_description TEXT,
114114+ post_count INTEGER,
115115+ follower_count INTEGER,
116116+ match_score INTEGER NOT NULL,
117117+ found_at TIMESTAMP DEFAULT NOW(),
118118+ last_verified TIMESTAMP,
119119+ is_active BOOLEAN DEFAULT TRUE,
120120+ follow_status JSONB DEFAULT '{}',
121121+ last_follow_check TIMESTAMP,
122122+ UNIQUE(source_account_id, atproto_did)
123123+ )
124124+ `;
125125+126126+ await this.sql`
127127+ CREATE TABLE IF NOT EXISTS user_match_status (
128128+ id SERIAL PRIMARY KEY,
129129+ did TEXT NOT NULL,
130130+ atproto_match_id INTEGER NOT NULL REFERENCES atproto_matches(id) ON DELETE CASCADE,
131131+ source_account_id INTEGER NOT NULL REFERENCES source_accounts(id) ON DELETE CASCADE,
132132+ notified BOOLEAN DEFAULT FALSE,
133133+ notified_at TIMESTAMP,
134134+ viewed BOOLEAN DEFAULT FALSE,
135135+ viewed_at TIMESTAMP,
136136+ followed BOOLEAN DEFAULT FALSE,
137137+ followed_at TIMESTAMP,
138138+ dismissed BOOLEAN DEFAULT FALSE,
139139+ dismissed_at TIMESTAMP,
140140+ UNIQUE(did, atproto_match_id)
141141+ )
142142+ `;
143143+144144+ await this.sql`
145145+ CREATE TABLE IF NOT EXISTS notification_queue (
146146+ id SERIAL PRIMARY KEY,
147147+ did TEXT NOT NULL,
148148+ new_matches_count INTEGER NOT NULL,
149149+ created_at TIMESTAMP DEFAULT NOW(),
150150+ sent BOOLEAN DEFAULT FALSE,
151151+ sent_at TIMESTAMP,
152152+ retry_count INTEGER DEFAULT 0,
153153+ last_error TEXT
154154+ )
155155+ `;
156156+ }
157157+158158+ private async createIndexes(): Promise<void> {
159159+ // Existing indexes
160160+ await this
161161+ .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`;
162162+ await this
163163+ .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`;
164164+ await this
165165+ .sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_did ON user_source_follows(did)`;
166166+ await this
167167+ .sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_source ON user_source_follows(source_account_id)`;
168168+ await this
169169+ .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source ON atproto_matches(source_account_id)`;
170170+ await this
171171+ .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_did ON atproto_matches(atproto_did)`;
172172+ await this
173173+ .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_notified ON user_match_status(did, notified, viewed)`;
174174+ await this
175175+ .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`;
176176+ await this
177177+ .sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`;
178178+179179+ // Enhanced indexes
180180+ await this
181181+ .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)`;
182182+ await this
183183+ .sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`;
184184+ await this
185185+ .sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at)`;
186186+ await this
187187+ .sql`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at)`;
188188+ await this
189189+ .sql`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires ON oauth_sessions(expires_at)`;
190190+ await this
191191+ .sql`CREATE INDEX IF NOT EXISTS idx_user_uploads_did_created ON user_uploads(did, created_at DESC)`;
192192+ await this
193193+ .sql`CREATE INDEX IF NOT EXISTS idx_user_source_follows_upload_created ON user_source_follows(upload_id, source_account_id)`;
194194+ await this
195195+ .sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_match_id ON user_match_status(atproto_match_id)`;
196196+ await this
197197+ .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_source_active ON atproto_matches(source_account_id, is_active) WHERE is_active = true`;
198198+ await this
199199+ .sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`;
200200+ await this
201201+ .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_status ON atproto_matches USING gin(follow_status)`;
202202+ await this
203203+ .sql`CREATE INDEX IF NOT EXISTS idx_atproto_matches_follow_check ON atproto_matches(last_follow_check)`;
204204+205205+ console.log("✅ Database indexes created/verified");
206206+ }
207207+208208+ async cleanupExpiredSessions(): Promise<void> {
209209+ try {
210210+ const statesDeleted = await this
211211+ .sql`DELETE FROM oauth_states WHERE expires_at < NOW()`;
212212+ const sessionsDeleted = await this
213213+ .sql`DELETE FROM oauth_sessions WHERE expires_at < NOW()`;
214214+ const userSessionsDeleted = await this
215215+ .sql`DELETE FROM user_sessions WHERE expires_at < NOW()`;
216216+217217+ console.log("🧹 Cleanup:", {
218218+ states: (statesDeleted as any).length,
219219+ sessions: (sessionsDeleted as any).length,
220220+ userSessions: (userSessionsDeleted as any).length,
221221+ });
222222+ } catch (error) {
223223+ throw new DatabaseError(
224224+ "Failed to cleanup expired sessions",
225225+ error instanceof Error ? error.message : "Unknown error",
226226+ );
227227+ }
228228+ }
229229+}