import type Database from 'better-sqlite3'; export type RedditAccountStatus = 'verified' | 'login-required' | 'disabled' | 'legacy-unassigned'; export interface RedditAccountPublicRecord { accountId: string; redditUsername: string; matrixUserId: string | null; status: RedditAccountStatus; isActive: boolean; createdAt: number; updatedAt: number; lastVerifiedAt: number | null; lastImportedAt: number | null; } /** Private credential-bearing record. Never return this from a web API. */ export interface RedditAccountPrivateRecord extends RedditAccountPublicRecord { cookiesJson: string; matrixCredentialsJson: string; } export interface VerifiedRedditAccountInput { accountId: string; redditUsername: string; matrixUserId: string; cookiesJson: string; matrixCredentialsJson: string; now?: number; } type AccountRow = { account_id: string; reddit_username: string; matrix_user_id: string | null; status: RedditAccountStatus; is_active: number; cookies_json: string; matrix_credentials_json: string; created_at: number; updated_at: number; last_verified_at: number | null; last_imported_at: number | null; }; export class RedditAccountsRepository { constructor(private readonly db: Database.Database) {} listPublic(): RedditAccountPublicRecord[] { const rows = this.db.prepare(` SELECT account_id, reddit_username, matrix_user_id, status, is_active, created_at, updated_at, last_verified_at, last_imported_at FROM reddit_accounts ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id `).all() as AccountRow[]; return rows.map(mapPublicAccount); } /** Private lookup for the runtime/session factory. Contains credentials. */ get(accountId: string): RedditAccountPrivateRecord | null { const row = this.db.prepare('SELECT * FROM reddit_accounts WHERE account_id = ?') .get(accountId) as AccountRow | undefined; return row ? mapPrivateAccount(row) : null; } getActive(): RedditAccountPublicRecord[] { const rows = this.db.prepare(` SELECT account_id, reddit_username, matrix_user_id, status, is_active, created_at, updated_at, last_verified_at, last_imported_at FROM reddit_accounts WHERE is_active = 1 AND status != 'legacy-unassigned' ORDER BY reddit_username COLLATE NOCASE, account_id `).all() as AccountRow[]; return rows.map(mapPublicAccount); } upsertVerified(input: VerifiedRedditAccountInput): RedditAccountPublicRecord { requireText(input.accountId, 'accountId'); requireText(input.redditUsername, 'redditUsername'); requireText(input.matrixUserId, 'matrixUserId'); requireJson(input.cookiesJson, 'cookiesJson'); requireJson(input.matrixCredentialsJson, 'matrixCredentialsJson'); const now = input.now ?? Date.now(); this.db.prepare(` INSERT INTO reddit_accounts ( account_id, reddit_username, matrix_user_id, status, is_active, cookies_json, matrix_credentials_json, created_at, updated_at, last_verified_at, last_imported_at ) VALUES ( @accountId, @redditUsername, @matrixUserId, 'verified', 1, @cookiesJson, @matrixCredentialsJson, @now, @now, @now, @now ) ON CONFLICT(account_id) DO UPDATE SET reddit_username = excluded.reddit_username, matrix_user_id = excluded.matrix_user_id, status = 'verified', is_active = 1, cookies_json = excluded.cookies_json, matrix_credentials_json = excluded.matrix_credentials_json, updated_at = excluded.updated_at, last_verified_at = excluded.last_verified_at, last_imported_at = excluded.last_imported_at `).run({ ...input, now }); return publicFromPrivate(this.getRequired(input.accountId)); } /** Store a self-refreshed Matrix token without touching cookies or status. */ updateMatrixCredentials( accountId: string, matrixCredentialsJson: string, now: number = Date.now(), ): boolean { requireText(accountId, 'accountId'); requireJson(matrixCredentialsJson, 'matrixCredentialsJson'); return this.db.prepare(` UPDATE reddit_accounts SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ? WHERE account_id = ? AND status != 'legacy-unassigned' `).run(matrixCredentialsJson, now, now, accountId).changes === 1; } setActive(accountId: string, active = true, now: number = Date.now()): boolean { const status = active ? 'verified' : 'disabled'; return this.db.prepare(` UPDATE reddit_accounts SET is_active = ?, status = ?, updated_at = ? WHERE account_id = ? AND status != 'legacy-unassigned' `).run(active ? 1 : 0, status, now, accountId).changes === 1; } markLoginRequired(accountId: string, now: number = Date.now()): boolean { return this.db.prepare(` UPDATE reddit_accounts SET status = 'login-required', updated_at = ? WHERE account_id = ? AND status != 'legacy-unassigned' `).run(now, accountId).changes === 1; } private getRequired(accountId: string): RedditAccountPrivateRecord { const account = this.get(accountId); if (!account) throw new Error(`Reddit account ${accountId} was not persisted`); return account; } } function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord { return { accountId: row.account_id, redditUsername: row.reddit_username, matrixUserId: row.matrix_user_id, status: row.status, isActive: row.is_active === 1, createdAt: row.created_at, updatedAt: row.updated_at, lastVerifiedAt: row.last_verified_at, lastImportedAt: row.last_imported_at, }; } function mapPrivateAccount(row: AccountRow): RedditAccountPrivateRecord { return { ...mapPublicAccount(row), cookiesJson: row.cookies_json, matrixCredentialsJson: row.matrix_credentials_json, }; } function publicFromPrivate(record: RedditAccountPrivateRecord): RedditAccountPublicRecord { const { cookiesJson: _cookies, matrixCredentialsJson: _matrix, ...publicRecord } = record; return publicRecord; } export interface AccountConversationRecord { accountId: string; conversationId: string; username: string; roomName: string | null; payloadJson: string; updatedAt: number; } export interface AccountSyncStateRecord { accountId: string; nextBatch: string | null; lastFullSyncAt: number | null; lastDeltaSyncAt: number | null; cooldownJson: string | null; updatedAt: number; } export interface AccountMediaRecord { accountId: string; mediaId: string; chatUsername: string | null; chatRoomId: string | null; sender: string; timestamp: number; originalUrl: string; fileName: string; relativePath: string; createdAt: number; } export type RedditRecipientAccountStatus = 'active' | 'suspended' | 'deleted'; export interface RedditRecipientAccountStatusRecord { username: string; status: RedditRecipientAccountStatus; checkedAt: number; } type ConversationRow = { account_id: string; conversation_id: string; username: string; room_name: string | null; payload_json: string; updated_at: number; }; type SyncStateRow = { account_id: string; next_batch: string | null; last_full_sync_at: number | null; last_delta_sync_at: number | null; cooldown_json: string | null; updated_at: number; }; type MediaRow = { account_id: string; media_id: string; chat_username: string | null; chat_room_id: string | null; sender: string; timestamp: number; original_url: string; file_name: string; relative_path: string; created_at: number; }; /** Account-scoped DB persistence for chat cache state and downloaded-media metadata. */ export class AccountContentRepository { constructor(private readonly db: Database.Database) {} upsertConversation(input: Omit & { updatedAt?: number }): void { requireText(input.accountId, 'accountId'); requireText(input.conversationId, 'conversationId'); requireText(input.username, 'username'); requireJson(input.payloadJson, 'payloadJson'); this.db.prepare(` INSERT INTO reddit_conversations ( account_id, conversation_id, username, room_name, payload_json, updated_at ) VALUES (@accountId, @conversationId, @username, @roomName, @payloadJson, @updatedAt) ON CONFLICT(account_id, conversation_id) DO UPDATE SET username = excluded.username, room_name = excluded.room_name, payload_json = excluded.payload_json, updated_at = excluded.updated_at `).run({ ...input, roomName: input.roomName ?? null, updatedAt: input.updatedAt ?? Date.now() }); } getConversation(accountId: string, conversationId: string): AccountConversationRecord | null { const row = this.db.prepare(` SELECT * FROM reddit_conversations WHERE account_id = ? AND conversation_id = ? `).get(accountId, conversationId) as ConversationRow | undefined; return row ? mapConversation(row) : null; } listConversations(accountId: string): AccountConversationRecord[] { const rows = this.db.prepare(` SELECT * FROM reddit_conversations WHERE account_id = ? ORDER BY updated_at DESC, conversation_id `).all(accountId) as ConversationRow[]; return rows.map(mapConversation); } deleteConversation(accountId: string, conversationId: string): boolean { return this.db.prepare(` DELETE FROM reddit_conversations WHERE account_id = ? AND conversation_id = ? `).run(accountId, conversationId).changes === 1; } saveSyncState(input: Omit & { updatedAt?: number }): void { if (input.cooldownJson !== null) requireJson(input.cooldownJson, 'cooldownJson'); this.db.prepare(` INSERT INTO reddit_account_sync_state ( account_id, next_batch, last_full_sync_at, last_delta_sync_at, cooldown_json, updated_at ) VALUES (@accountId, @nextBatch, @lastFullSyncAt, @lastDeltaSyncAt, @cooldownJson, @updatedAt) ON CONFLICT(account_id) DO UPDATE SET next_batch = excluded.next_batch, last_full_sync_at = excluded.last_full_sync_at, last_delta_sync_at = excluded.last_delta_sync_at, cooldown_json = excluded.cooldown_json, updated_at = excluded.updated_at `).run({ ...input, updatedAt: input.updatedAt ?? Date.now() }); } getSyncState(accountId: string): AccountSyncStateRecord | null { const row = this.db.prepare('SELECT * FROM reddit_account_sync_state WHERE account_id = ?') .get(accountId) as SyncStateRow | undefined; return row ? mapSyncState(row) : null; } upsertMedia(input: AccountMediaRecord): void { requireText(input.accountId, 'accountId'); requireText(input.mediaId, 'mediaId'); this.db.prepare(` INSERT INTO reddit_media ( account_id, media_id, chat_username, chat_room_id, sender, timestamp, original_url, file_name, relative_path, created_at ) VALUES ( @accountId, @mediaId, @chatUsername, @chatRoomId, @sender, @timestamp, @originalUrl, @fileName, @relativePath, @createdAt ) ON CONFLICT(account_id, media_id) DO UPDATE SET chat_username = excluded.chat_username, chat_room_id = excluded.chat_room_id, sender = excluded.sender, timestamp = excluded.timestamp, original_url = excluded.original_url, file_name = excluded.file_name, relative_path = excluded.relative_path, created_at = excluded.created_at `).run(input); } getMedia(accountId: string, mediaId: string): AccountMediaRecord | null { const row = this.db.prepare(` SELECT * FROM reddit_media WHERE account_id = ? AND media_id = ? `).get(accountId, mediaId) as MediaRow | undefined; return row ? mapMedia(row) : null; } getMediaByOriginalUrl(accountId: string, originalUrl: string): AccountMediaRecord | null { const row = this.db.prepare(` SELECT * FROM reddit_media WHERE account_id = ? AND original_url = ? ORDER BY timestamp, media_id LIMIT 1 `).get(accountId, originalUrl) as MediaRow | undefined; return row ? mapMedia(row) : null; } listMedia(accountId: string): AccountMediaRecord[] { const rows = this.db.prepare(` SELECT * FROM reddit_media WHERE account_id = ? ORDER BY timestamp DESC, media_id `).all(accountId) as MediaRow[]; return rows.map(mapMedia); } deleteMedia(accountId: string, mediaId: string): boolean { return this.db.prepare(` DELETE FROM reddit_media WHERE account_id = ? AND media_id = ? `).run(accountId, mediaId).changes === 1; } } type RecipientAccountStatusRow = { username: string; status: RedditRecipientAccountStatus; checked_at: number; }; /** Process-global Reddit profile availability, shared by conversations across owning accounts. */ export class RedditRecipientAccountStatusRepository { constructor(private readonly db: Database.Database) {} get(username: string): RedditRecipientAccountStatusRecord | null { const row = this.db.prepare(` SELECT username, status, checked_at FROM reddit_recipient_account_status WHERE username = ? COLLATE NOCASE `).get(username) as RecipientAccountStatusRow | undefined; return row ? { username: row.username, status: row.status, checkedAt: row.checked_at } : null; } upsert(username: string, status: RedditRecipientAccountStatus, checkedAt = Date.now()): void { requireText(username, 'username'); this.db.prepare(` INSERT INTO reddit_recipient_account_status (username, status, checked_at) VALUES (?, ?, ?) ON CONFLICT(username) DO UPDATE SET status = excluded.status, checked_at = excluded.checked_at `).run(username, status, checkedAt); } } function mapConversation(row: ConversationRow): AccountConversationRecord { return { accountId: row.account_id, conversationId: row.conversation_id, username: row.username, roomName: row.room_name, payloadJson: row.payload_json, updatedAt: row.updated_at, }; } function mapSyncState(row: SyncStateRow): AccountSyncStateRecord { return { accountId: row.account_id, nextBatch: row.next_batch, lastFullSyncAt: row.last_full_sync_at, lastDeltaSyncAt: row.last_delta_sync_at, cooldownJson: row.cooldown_json, updatedAt: row.updated_at, }; } function mapMedia(row: MediaRow): AccountMediaRecord { return { accountId: row.account_id, mediaId: row.media_id, chatUsername: row.chat_username, chatRoomId: row.chat_room_id, sender: row.sender, timestamp: row.timestamp, originalUrl: row.original_url, fileName: row.file_name, relativePath: row.relative_path, createdAt: row.created_at, }; } function requireText(value: string, name: string): void { if (!value.trim()) throw new Error(`${name} must not be empty`); } function requireJson(value: string, name: string): void { try { JSON.parse(value); } catch { throw new Error(`${name} must be valid JSON`); } }