import type Database from 'better-sqlite3'; import type { GrowthQuietHours } from './growth-repository.js'; export type BrowsingThingKind = 'post' | 'comment'; export type BrowsingDecisionKind = 'simulated-upvote' | 'skipped' | 'rejected'; export type BrowsingVoteStatus = 'simulated' | 'pending' | 'submitted' | 'failed'; export interface AccountBrowsingSettingsRecord { accountId: string; enabled: boolean; dailyTargetMin: number; dailyTargetMax: number; postWeight: number; commentWeight: number; quietHours: GrowthQuietHours; minimumGapMs: number; jitterMs: number; allowNsfw: boolean; autoVoteEnabled: boolean; nextDueAt: number | null; updatedAt: number; } export interface AccountBrowsingSettingsPatch { enabled?: boolean; dailyTargetMin?: number; dailyTargetMax?: number; postWeight?: number; commentWeight?: number; quietHours?: GrowthQuietHours; minimumGapMs?: number; jitterMs?: number; allowNsfw?: boolean; autoVoteEnabled?: boolean; } export interface BrowsingDecisionRecord { accountId: string; thingFullname: string; thingKind: BrowsingThingKind; postId: string; subreddit: string; author: string; permalink: string; decision: BrowsingDecisionKind; reason: string; discoveredAt: number; decidedAt: number; scheduledSessionId: string; voteSubmitted: boolean; voteStatus: BrowsingVoteStatus; } export interface BrowsingDecisionCounts { selected: number; posts: number; comments: number; rejected: number; skipped: number; } const DEFAULT_QUIET_HOURS: GrowthQuietHours = { enabled: true, startHour: 0, endHour: 7 }; /** Durable account-scoped policy and audit store for simulation-only browsing decisions. */ export class AccountBrowsingSimulationRepository { constructor(private readonly db: Database.Database) {} getSettings(accountId: string, now: number = Date.now()): AccountBrowsingSettingsRecord { const row = this.db.prepare('SELECT * FROM account_browsing_settings WHERE account_id = ?') .get(accountId) as Record | undefined; return row ? mapSettings(row) : { accountId, enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, postWeight: 60, commentWeight: 40, quietHours: { ...DEFAULT_QUIET_HOURS }, minimumGapMs: 45 * 60_000, jitterMs: 15 * 60_000, allowNsfw: false, autoVoteEnabled: false, nextDueAt: null, updatedAt: now, }; } saveSettings( accountId: string, patch: AccountBrowsingSettingsPatch, now: number = Date.now(), ): AccountBrowsingSettingsRecord { const current = this.getSettings(accountId, now); const merged = { ...current, ...patch, quietHours: patch.quietHours ?? current.quietHours, updatedAt: now, }; validateSettings(merged); this.db.prepare(` INSERT INTO account_browsing_settings ( account_id, enabled, daily_target_min, daily_target_max, post_weight, comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw, auto_vote_enabled, next_due_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET enabled = excluded.enabled, daily_target_min = excluded.daily_target_min, daily_target_max = excluded.daily_target_max, post_weight = excluded.post_weight, comment_weight = excluded.comment_weight, quiet_hours_json = excluded.quiet_hours_json, minimum_gap_ms = excluded.minimum_gap_ms, jitter_ms = excluded.jitter_ms, allow_nsfw = excluded.allow_nsfw, auto_vote_enabled = excluded.auto_vote_enabled, updated_at = excluded.updated_at `).run( accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax, merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours), merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0, merged.nextDueAt, now, ); return this.getSettings(accountId, now); } setNextDueAt(accountId: string, nextDueAt: number | null, now: number = Date.now()): void { if (!this.db.prepare('SELECT 1 FROM account_browsing_settings WHERE account_id = ?').get(accountId)) { this.saveSettings(accountId, {}, now); } this.db.prepare(` UPDATE account_browsing_settings SET next_due_at = ?, updated_at = ? WHERE account_id = ? `).run(nextDueAt, now, accountId); } recordDecision(decision: BrowsingDecisionRecord): boolean { return this.db.prepare(` INSERT OR IGNORE INTO account_browsing_decisions ( account_id, thing_fullname, thing_kind, post_id, subreddit, author, permalink, decision, reason, discovered_at, decided_at, scheduled_session_id, vote_submitted, vote_status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( decision.accountId, decision.thingFullname, decision.thingKind, decision.postId, decision.subreddit, decision.author, decision.permalink, decision.decision, decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId, decision.voteSubmitted ? 1 : 0, decision.voteStatus, ).changes === 1; } async recordSimulation(decision: BrowsingDecisionRecord): Promise { if (!this.recordDecision(decision)) { throw new Error(`Browsing decision for ${decision.thingFullname} was already recorded`); } } markVoteSubmitted(accountId: string, thingFullname: string): boolean { return this.db.prepare(` UPDATE account_browsing_decisions SET vote_submitted = 1, vote_status = 'submitted', reason = 'Reddit upvote submitted' WHERE account_id = ? AND thing_fullname = ? AND vote_status = 'pending' `).run(accountId, thingFullname).changes === 1; } markVoteFailed(accountId: string, thingFullname: string, reason: string): boolean { return this.db.prepare(` UPDATE account_browsing_decisions SET vote_status = 'failed', reason = ? WHERE account_id = ? AND thing_fullname = ? AND vote_status = 'pending' `).run(reason, accountId, thingFullname).changes === 1; } hasAccountDecision(accountId: string, thingFullname: string): boolean { return this.db.prepare(` SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ? `).get(accountId, thingFullname) !== undefined; } wasSelectedByAnyAccount(thingFullname: string): boolean { return this.db.prepare(` SELECT 1 FROM account_browsing_decisions WHERE thing_fullname = ? AND decision = 'simulated-upvote' LIMIT 1 `).get(thingFullname) !== undefined; } isOwnedAuthor(author: string): boolean { return this.db.prepare(` SELECT 1 FROM reddit_accounts WHERE reddit_username = ? COLLATE NOCASE LIMIT 1 `).get(author) !== undefined; } isOutreachRecipient(author: string): boolean { return this.db.prepare(` SELECT 1 FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE LIMIT 1 `).get(author) !== undefined; } isGrowthCommentPost(postId: string): boolean { return this.db.prepare(`SELECT 1 FROM account_growth_comments WHERE post_id = ? LIMIT 1`) .get(postId) !== undefined; } isPromotionExperimentPost(postId: string): boolean { return this.db.prepare(` SELECT 1 WHERE EXISTS ( SELECT 1 FROM sent_messages WHERE post_id = ? AND experiment_id IS NOT NULL AND trim(experiment_id) <> '' ) OR EXISTS ( SELECT 1 FROM candidate_queue WHERE post_id = ? AND prepared_json IS NOT NULL AND json_valid(prepared_json) AND json_extract(prepared_json, '$.experimentId') IS NOT NULL ) LIMIT 1 `).get(postId, postId) !== undefined; } countSelectedSince(accountId: string, since: number): number { return (this.db.prepare(` SELECT COUNT(*) AS count FROM account_browsing_decisions WHERE account_id = ? AND decision = 'simulated-upvote' AND decided_at >= ? `).get(accountId, since) as { count: number }).count; } getCountsSince(accountId: string, since: number): BrowsingDecisionCounts { const rows = this.db.prepare(` SELECT decision, thing_kind, COUNT(*) AS count FROM account_browsing_decisions WHERE account_id = ? AND decided_at >= ? GROUP BY decision, thing_kind `).all(accountId, since) as Array<{ decision: BrowsingDecisionKind; thing_kind: BrowsingThingKind; count: number }>; const counts: BrowsingDecisionCounts = { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 }; for (const row of rows) { if (row.decision === 'simulated-upvote') { counts.selected += row.count; counts[row.thing_kind === 'post' ? 'posts' : 'comments'] += row.count; } else if (row.decision === 'rejected') counts.rejected += row.count; else counts.skipped += row.count; } return counts; } listRecent(accountId: string, limit = 20): BrowsingDecisionRecord[] { const rows = this.db.prepare(` SELECT * FROM account_browsing_decisions WHERE account_id = ? ORDER BY decided_at DESC LIMIT ? `).all(accountId, limit) as Array>; return rows.map(mapDecision); } listRecentSelections(accountId: string, limit = 20): BrowsingDecisionRecord[] { const rows = this.db.prepare(` SELECT * FROM account_browsing_decisions WHERE account_id = ? AND decision = 'simulated-upvote' ORDER BY decided_at DESC LIMIT ? `).all(accountId, limit) as Array>; return rows.map(mapDecision); } } function validateSettings(settings: AccountBrowsingSettingsRecord): void { if (!Number.isInteger(settings.dailyTargetMin) || settings.dailyTargetMin < 1 || settings.dailyTargetMin > 12) { throw new Error('Browsing minimum daily target must be a whole number between 1 and 12'); } if (!Number.isInteger(settings.dailyTargetMax) || settings.dailyTargetMax < settings.dailyTargetMin || settings.dailyTargetMax > 12) { throw new Error('Browsing maximum daily target must be between the minimum and 12'); } if (!Number.isFinite(settings.postWeight) || settings.postWeight < 0 || !Number.isFinite(settings.commentWeight) || settings.commentWeight < 0 || settings.postWeight + settings.commentWeight <= 0) { throw new Error('Browsing post and comment weights must be non-negative with a positive total'); } if (!Number.isInteger(settings.minimumGapMs) || settings.minimumGapMs < 45 * 60_000) { throw new Error('Browsing minimum gap cannot be less than 45 minutes'); } if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) { throw new Error('Browsing jitter must be a non-negative whole number'); } const { startHour, endHour } = settings.quietHours; if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23 || !Number.isInteger(endHour) || endHour < 0 || endHour > 23) { throw new Error('Browsing quiet-hour boundaries must be whole hours from 0 to 23'); } } function mapSettings(row: Record): AccountBrowsingSettingsRecord { return { accountId: row.account_id as string, enabled: row.enabled === 1, dailyTargetMin: row.daily_target_min as number, dailyTargetMax: row.daily_target_max as number, postWeight: row.post_weight as number, commentWeight: row.comment_weight as number, quietHours: JSON.parse(row.quiet_hours_json as string) as GrowthQuietHours, minimumGapMs: row.minimum_gap_ms as number, jitterMs: row.jitter_ms as number, allowNsfw: row.allow_nsfw === 1, autoVoteEnabled: row.auto_vote_enabled === 1, nextDueAt: (row.next_due_at as number | null) ?? null, updatedAt: row.updated_at as number, }; } function mapDecision(row: Record): BrowsingDecisionRecord { return { accountId: row.account_id as string, thingFullname: row.thing_fullname as string, thingKind: row.thing_kind as BrowsingThingKind, postId: row.post_id as string, subreddit: row.subreddit as string, author: row.author as string, permalink: row.permalink as string, decision: row.decision as BrowsingDecisionKind, reason: row.reason as string, discoveredAt: row.discovered_at as number, decidedAt: row.decided_at as number, scheduledSessionId: row.scheduled_session_id as string, voteSubmitted: row.vote_submitted === 1, voteStatus: row.vote_status as BrowsingVoteStatus, }; }