import type Database from 'better-sqlite3'; export interface ProcessedPostRecord { id: string; subreddit: string; author: string; title: string; imageUrl: string | null; imageAnalysis: string | null; generatedMessage: string | null; processedAt: number; messageSent: boolean; error: string | null; } export interface SentMessageRecord { id: number; postId: string | null; recipient: string; subject: string; message: string; sentAt: number; gotResponse: boolean; responseCheckedAt: number | null; strategyId: string | null; chatRoomId: string | null; subreddit?: string; postTitle?: string; postImageUrl?: string; postImageAnalysis?: string; } export interface SuccessfulMessageRecord { id: number; sentMessageId: number; originalMessage: string; imageAnalysis: string | null; postTitle: string | null; responseSnippet: string | null; subreddit: string | null; createdAt: number; } export interface StrategyStats { strategyId: string; totalSent: number; totalResponses: number; responseRate: number; } export interface SubredditStats { subreddit: string; totalSent: number; totalResponses: number; responseRate: number; } export interface DailyTrend { date: string; totalSent: number; totalResponses: number; responseRate: number; } export interface ActivityLogRecord { timestamp: number; type: string; message: string; recipient: string | null; imageUrl: string | null; } export class ActivityLogRepository { private insertStmt: Database.Statement; private getRecentStmt: Database.Statement; private pruneStmt: Database.Statement; private countSentSkippedStmt: Database.Statement; constructor(private db: Database.Database) { this.insertStmt = db.prepare(` INSERT INTO activity_log (timestamp, type, message, recipient, image_url) VALUES (?, ?, ?, ?, ?) `); this.getRecentStmt = db.prepare(` SELECT timestamp, type, message, recipient, image_url FROM activity_log ORDER BY id DESC LIMIT ? `); this.pruneStmt = db.prepare(` DELETE FROM activity_log WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT ?) `); this.countSentSkippedStmt = db.prepare(` SELECT COUNT(*) AS count FROM activity_log WHERE type IN ('sent', 'skipped') `); } insert(item: { timestamp: number; type: string; message: string; recipient?: string; imageUrl?: string; }): void { this.insertStmt.run( item.timestamp, item.type, item.message, item.recipient ?? null, item.imageUrl ?? null, ); } getRecent(limit: number): ActivityLogRecord[] { const rows = this.getRecentStmt.all(limit) as Record[]; return rows.map((row) => ({ timestamp: row.timestamp as number, type: row.type as string, message: row.message as string, recipient: (row.recipient as string) ?? null, imageUrl: (row.image_url as string) ?? null, })); } prune(keep: number): void { this.pruneStmt.run(keep); } countSentSkipped(): number { const row = this.countSentSkippedStmt.get() as { count: number }; return row.count; } } export class PostsRepository { private insertStmt: Database.Statement; private existsStmt: Database.Statement; private markSentStmt: Database.Statement; private updateAnalysisStmt: Database.Statement; private setErrorStmt: Database.Statement; private getByIdStmt: Database.Statement; private getByAuthorStmt: Database.Statement; constructor(private db: Database.Database) { this.insertStmt = db.prepare(` INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at) VALUES (?, ?, ?, ?, ?, ?) `); this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`); this.markSentStmt = db.prepare(`UPDATE processed_posts SET message_sent = 1 WHERE id = ?`); this.updateAnalysisStmt = db.prepare(` UPDATE processed_posts SET image_analysis = ?, generated_message = ? WHERE id = ? `); this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE id = ?`); this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE id = ?`); this.getByAuthorStmt = db.prepare(` SELECT * FROM processed_posts WHERE author = ? COLLATE NOCASE ORDER BY processed_at DESC LIMIT 10 `); } isProcessed(postId: string): boolean { return this.existsStmt.get(postId) !== undefined; } insert(post: { id: string; subreddit: string; author: string; title: string; imageUrl: string | null; processedAt: number; }): void { this.insertStmt.run( post.id, post.subreddit, post.author, post.title, post.imageUrl, post.processedAt ); } updateAnalysis(postId: string, analysis: string, message: string): void { this.updateAnalysisStmt.run(analysis, message, postId); } markMessageSent(postId: string): void { this.markSentStmt.run(postId); } setError(postId: string, error: string): void { this.setErrorStmt.run(error, postId); } getById(postId: string): ProcessedPostRecord | undefined { const row = this.getByIdStmt.get(postId) as Record | undefined; if (!row) return undefined; return { id: row.id as string, subreddit: row.subreddit as string, author: row.author as string, title: row.title as string, imageUrl: row.image_url as string | null, imageAnalysis: row.image_analysis as string | null, generatedMessage: row.generated_message as string | null, processedAt: row.processed_at as number, messageSent: Boolean(row.message_sent), error: row.error as string | null, }; } getByAuthor(author: string): ProcessedPostRecord[] { const rows = this.getByAuthorStmt.all(author) as Record[]; return rows.map((row) => ({ id: row.id as string, subreddit: row.subreddit as string, author: row.author as string, title: row.title as string, imageUrl: row.image_url as string | null, imageAnalysis: row.image_analysis as string | null, generatedMessage: row.generated_message as string | null, processedAt: row.processed_at as number, messageSent: Boolean(row.message_sent), error: row.error as string | null, })); } } export class MessagesRepository { private insertStmt: Database.Statement; private insertChatStmt: Database.Statement; private getByPostIdStmt: Database.Statement; private getByChatRoomIdStmt: Database.Statement; private getLatestByRecipientStmt: Database.Statement; private getRecentStmt: Database.Statement; private getUncheckedStmt: Database.Statement; private markResponseStmt: Database.Statement; private markCheckedStmt: Database.Statement; private hasSentToStmt: Database.Statement; constructor(private db: Database.Database) { this.insertStmt = db.prepare(` INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id) VALUES (?, ?, ?, ?, ?, ?) `); this.insertChatStmt = db.prepare(` INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, chat_room_id) VALUES (NULL, ?, ?, ?, ?, ?) `); this.getByChatRoomIdStmt = db.prepare(` SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url, pp.image_analysis AS post_image_analysis FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.post_id = pp.id WHERE sm.chat_room_id = ? LIMIT 1 `); this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`); this.getLatestByRecipientStmt = db.prepare(` SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url, pp.image_analysis AS post_image_analysis FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.post_id = pp.id WHERE sm.recipient = ? COLLATE NOCASE ORDER BY sm.sent_at DESC LIMIT 1 `); this.getRecentStmt = db.prepare(` SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url, pp.image_analysis AS post_image_analysis FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.post_id = pp.id ORDER BY sm.sent_at DESC LIMIT ? `); this.getUncheckedStmt = db.prepare(` SELECT * FROM sent_messages WHERE got_response = 0 AND (response_checked_at IS NULL OR response_checked_at < ?) AND sent_at < ? ORDER BY sent_at ASC LIMIT ? `); this.markResponseStmt = db.prepare(` UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ? `); this.markCheckedStmt = db.prepare(` UPDATE sent_messages SET response_checked_at = ? WHERE id = ? `); this.hasSentToStmt = db.prepare(` SELECT 1 FROM sent_messages WHERE recipient = ? LIMIT 1 `); } insert(message: { postId: string; recipient: string; subject: string; message: string; sentAt: number; strategyId?: string | null; }): number { const result = this.insertStmt.run( message.postId, message.recipient, message.subject, message.message, message.sentAt, message.strategyId || null ); return result.lastInsertRowid as number; } insertChat(message: { recipient: string; subject: string; message: string; sentAt: number; chatRoomId: string; }): number { const result = this.insertChatStmt.run( message.recipient, message.subject, message.message, message.sentAt, message.chatRoomId, ); return result.lastInsertRowid as number; } getByPostId(postId: string): SentMessageRecord | undefined { const row = this.getByPostIdStmt.get(postId) as Record | undefined; if (!row) return undefined; return this.mapRow(row); } getByChatRoomId(chatRoomId: string): SentMessageRecord | undefined { const row = this.getByChatRoomIdStmt.get(chatRoomId) as Record | undefined; if (!row) return undefined; return this.mapRow(row); } getLatestByRecipient(recipient: string): SentMessageRecord | undefined { const row = this.getLatestByRecipientStmt.get(recipient) as Record | undefined; if (!row) return undefined; return this.mapRow(row); } getRecent(limit: number = 10): SentMessageRecord[] { const rows = this.getRecentStmt.all(limit) as Record[]; return rows.map((row) => this.mapRow(row)); } getUncheckedMessages(minAgeMs: number, recheckAfterMs: number, limit: number = 10): SentMessageRecord[] { const now = Date.now(); const minSentAt = now - minAgeMs; const recheckBefore = now - recheckAfterMs; const rows = this.getUncheckedStmt.all(recheckBefore, minSentAt, limit) as Record[]; return rows.map((row) => this.mapRow(row)); } markGotResponse(messageId: number): void { this.markResponseStmt.run(Date.now(), messageId); } markChecked(messageId: number): void { this.markCheckedStmt.run(Date.now(), messageId); } hasSentTo(recipient: string): boolean { return this.hasSentToStmt.get(recipient) !== undefined; } getStrategyStats(): StrategyStats[] { const rows = this.db.prepare(` SELECT strategy_id, COUNT(*) as total_sent, SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses FROM sent_messages WHERE strategy_id IS NOT NULL GROUP BY strategy_id `).all() as Record[]; return rows.map(row => ({ strategyId: row.strategy_id as string, totalSent: row.total_sent as number, totalResponses: row.total_responses as number, responseRate: (row.total_sent as number) > 0 ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) : 0, })); } getSubredditStats(): SubredditStats[] { const rows = this.db.prepare(` SELECT pp.subreddit, COUNT(*) as total_sent, SUM(CASE WHEN sm.got_response = 1 THEN 1 ELSE 0 END) as total_responses FROM sent_messages sm JOIN processed_posts pp ON sm.post_id = pp.id GROUP BY pp.subreddit ORDER BY total_sent DESC `).all() as Record[]; return rows.map(row => ({ subreddit: row.subreddit as string, totalSent: row.total_sent as number, totalResponses: row.total_responses as number, responseRate: (row.total_sent as number) > 0 ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) : 0, })); } getResponseRateTrend(days: number = 30): DailyTrend[] { const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); const rows = this.db.prepare(` SELECT date(sent_at / 1000, 'unixepoch') as day, COUNT(*) as total_sent, SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses FROM sent_messages WHERE sent_at > ? GROUP BY day ORDER BY day ASC `).all(cutoff) as Record[]; return rows.map(row => ({ date: row.day as string, totalSent: row.total_sent as number, totalResponses: row.total_responses as number, responseRate: (row.total_sent as number) > 0 ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) : 0, })); } private mapRow(row: Record): SentMessageRecord { return { id: row.id as number, postId: (row.post_id as string) ?? null, recipient: row.recipient as string, subject: row.subject as string, message: row.message as string, sentAt: row.sent_at as number, gotResponse: Boolean(row.got_response), responseCheckedAt: row.response_checked_at as number | null, strategyId: (row.strategy_id as string) || null, chatRoomId: (row.chat_room_id as string) ?? null, subreddit: (row.subreddit as string) || undefined, postTitle: (row.post_title as string) || undefined, postImageUrl: (row.post_image_url as string) || undefined, postImageAnalysis: (row.post_image_analysis as string) || undefined, }; } } export class SuccessfulMessagesRepository { private insertStmt: Database.Statement; private getRecentStmt: Database.Statement; private getBySubredditStmt: Database.Statement; private countStmt: Database.Statement; private existsByMessageStmt: Database.Statement; constructor(private db: Database.Database) { this.insertStmt = db.prepare(` INSERT INTO successful_messages (sent_message_id, original_message, image_analysis, post_title, response_snippet, subreddit, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `); this.getRecentStmt = db.prepare(` SELECT * FROM successful_messages ORDER BY created_at DESC, id DESC LIMIT ? `); // Get examples prioritizing same subreddit, then falling back to others this.getBySubredditStmt = db.prepare(` SELECT *, CASE WHEN subreddit = ? THEN 1 ELSE 0 END as subreddit_match FROM successful_messages ORDER BY subreddit_match DESC, created_at DESC LIMIT ? `); this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`); this.existsByMessageStmt = db.prepare(` SELECT 1 FROM successful_messages WHERE original_message = ? LIMIT 1 `); } insert(data: { sentMessageId: number | null; originalMessage: string; imageAnalysis: string | null; postTitle: string | null; responseSnippet: string | null; subreddit: string | null; }): void { this.insertStmt.run( data.sentMessageId, data.originalMessage, data.imageAnalysis, data.postTitle, data.responseSnippet, data.subreddit, Date.now() ); } existsByMessage(originalMessage: string): boolean { return this.existsByMessageStmt.get(originalMessage) !== undefined; } getRecent(limit: number = 5): SuccessfulMessageRecord[] { const rows = this.getRecentStmt.all(limit) as Record[]; return rows.map((row) => this.mapRow(row)); } getWeightedExamples(subreddit: string | null, limit: number = 3): SuccessfulMessageRecord[] { if (subreddit) { const rows = this.getBySubredditStmt.all(subreddit, limit) as Record[]; return rows.map((row) => this.mapRow(row)); } return this.getRecent(limit); } count(): number { const result = this.countStmt.get() as { count: number }; return result.count; } private mapRow(row: Record): SuccessfulMessageRecord { return { id: row.id as number, sentMessageId: row.sent_message_id as number, originalMessage: row.original_message as string, imageAnalysis: row.image_analysis as string | null, postTitle: row.post_title as string | null, responseSnippet: row.response_snippet as string | null, subreddit: row.subreddit as string | null, createdAt: row.created_at as number, }; } } export interface RedditUserRecord { t2Id: string; username: string; mxid: string | null; updatedAt: number; } /** * Durable mapping of Reddit account IDs (`t2_xxx`) to current usernames. * Populated as the chat sync resolves IDs via Reddit's alfred/user_data * endpoints; read back on startup so we never have to show raw `t2_xxx` * or MXIDs in the UI just because the in-memory cache is cold. */ export class RedditUsersRepository { private upsertStmt: Database.Statement; private getByT2Stmt: Database.Statement; private getByUsernameStmt: Database.Statement; private getAllStmt: Database.Statement; constructor(private db: Database.Database) { this.upsertStmt = db.prepare(` INSERT INTO reddit_users (t2_id, username, mxid, updated_at) VALUES (@t2Id, @username, @mxid, @updatedAt) ON CONFLICT(t2_id) DO UPDATE SET username = excluded.username, mxid = COALESCE(excluded.mxid, reddit_users.mxid), updated_at = excluded.updated_at WHERE excluded.username != '' `); this.getByT2Stmt = db.prepare(`SELECT * FROM reddit_users WHERE t2_id = ?`); this.getByUsernameStmt = db.prepare(`SELECT * FROM reddit_users WHERE username = ? COLLATE NOCASE`); this.getAllStmt = db.prepare(`SELECT * FROM reddit_users`); } upsert(record: { t2Id: string; username: string; mxid?: string | null }): void { if (!record.t2Id || !record.username) return; if (record.username.startsWith('t2_') || record.username.startsWith('@')) return; const t2Id = record.t2Id.startsWith('t2_') ? record.t2Id : `t2_${record.t2Id}`; this.upsertStmt.run({ t2Id, username: record.username, mxid: record.mxid ?? null, updatedAt: Date.now(), }); } upsertMany(records: Array<{ t2Id: string; username: string; mxid?: string | null }>): void { const tx = this.db.transaction((items: typeof records) => { for (const r of items) this.upsert(r); }); tx(records); } getByT2(t2Id: string): RedditUserRecord | null { const normalized = t2Id.startsWith('t2_') ? t2Id : `t2_${t2Id}`; const row = this.getByT2Stmt.get(normalized) as Record | undefined; return row ? this.mapRow(row) : null; } getByUsername(username: string): RedditUserRecord | null { const row = this.getByUsernameStmt.get(username) as Record | undefined; return row ? this.mapRow(row) : null; } /** Bulk load — used to seed the in-memory cache on startup. */ getAll(): RedditUserRecord[] { const rows = this.getAllStmt.all() as Record[]; return rows.map((r) => this.mapRow(r)); } private mapRow(row: Record): RedditUserRecord { return { t2Id: row.t2_id as string, username: row.username as string, mxid: (row.mxid as string | null) ?? null, updatedAt: row.updated_at as number, }; } }