diff --git a/cruciverb-server/src/db.rs b/cruciverb-server/src/db.rs new file mode 100644 index 0000000..a32c753 --- /dev/null +++ b/cruciverb-server/src/db.rs @@ -0,0 +1,4022 @@ +use std::path::Path; + +use rusqlite::{params, Connection}; + +pub fn init_db(path: &Path) -> Connection { + let db = Connection::open(path).expect("failed to open cruciverb.db"); + db.execute_batch("PRAGMA busy_timeout = 5000;").unwrap(); + db.execute_batch("PRAGMA journal_mode = DELETE;").unwrap(); + + db.execute_batch(" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + display_name TEXT, + created_at TEXT NOT NULL, + last_seen TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS puzzles ( + id TEXT PRIMARY KEY, + short_id TEXT NOT NULL UNIQUE, + daily_date TEXT, + preset TEXT, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + difficulty INTEGER NOT NULL, + puzzle_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_puzzles_daily ON puzzles(daily_date, preset); + CREATE INDEX IF NOT EXISTS idx_puzzles_short ON puzzles(short_id); + + -- one canonical row per daily puzzle: drop any historical duplicates, + -- then enforce it so concurrent generation can never double-store + DELETE FROM puzzles WHERE daily_date IS NOT NULL AND rowid NOT IN ( + SELECT MIN(rowid) FROM puzzles WHERE daily_date IS NOT NULL GROUP BY daily_date, preset + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_puzzles_daily_unique ON puzzles(daily_date, preset); + + CREATE TABLE IF NOT EXISTS solve_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_token TEXT NOT NULL, + puzzle_id TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + solve_time_secs INTEGER, + hints_used INTEGER NOT NULL DEFAULT 0, + is_complete INTEGER NOT NULL DEFAULT 0, + progress_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_solve_session ON solve_records(session_token, completed_at DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_solve_unique ON solve_records(session_token, puzzle_id); + CREATE INDEX IF NOT EXISTS idx_solve_records_puzzle ON solve_records(puzzle_id); + + CREATE TABLE IF NOT EXISTS streaks ( + session_token TEXT PRIMARY KEY, + current_streak INTEGER NOT NULL DEFAULT 0, + best_streak INTEGER NOT NULL DEFAULT 0, + last_solve_date TEXT + ); + + CREATE TABLE IF NOT EXISTS achievements ( + session_token TEXT NOT NULL, + achievement_id TEXT NOT NULL, + unlocked_at TEXT NOT NULL, + PRIMARY KEY (session_token, achievement_id) + ); + + CREATE TABLE IF NOT EXISTS settings ( + session_token TEXT PRIMARY KEY, + settings_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE IF NOT EXISTS community_clues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + word TEXT NOT NULL, + clue_text TEXT NOT NULL, + submitted_by TEXT NOT NULL, + upvotes INTEGER NOT NULL DEFAULT 0, + downvotes INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cc_word ON community_clues(word, status); + CREATE INDEX IF NOT EXISTS idx_cc_submitter ON community_clues(submitted_by); + + CREATE TABLE IF NOT EXISTS clue_votes ( + session_token TEXT NOT NULL, + clue_id INTEGER NOT NULL, + vote INTEGER NOT NULL, + PRIMARY KEY (session_token, clue_id) + ); + + CREATE TABLE IF NOT EXISTS clue_quality ( + word TEXT NOT NULL, + clue_text TEXT NOT NULL, + total_score INTEGER NOT NULL DEFAULT 0, + rating_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (word, clue_text) + ); + + CREATE TABLE IF NOT EXISTS word_quality ( + word TEXT PRIMARY KEY, + total_score INTEGER NOT NULL DEFAULT 0, + rating_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS puzzle_ratings ( + session_token TEXT NOT NULL, + puzzle_id TEXT NOT NULL, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + created_at TEXT NOT NULL, + PRIMARY KEY (session_token, puzzle_id) + ); + CREATE INDEX IF NOT EXISTS idx_pr_puzzle ON puzzle_ratings(puzzle_id); + + CREATE TABLE IF NOT EXISTS user_puzzles ( + id TEXT PRIMARY KEY, + session_token TEXT NOT NULL, + title TEXT NOT NULL DEFAULT 'Untitled', + width INTEGER NOT NULL, + height INTEGER NOT NULL, + grid_json TEXT NOT NULL, + clues_json TEXT, + puzzle_json TEXT, + status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_up_session ON user_puzzles(session_token, status); + + CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + events TEXT NOT NULL, + secret TEXT, + format TEXT NOT NULL DEFAULT 'json', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS ap_keys ( + id INTEGER PRIMARY KEY CHECK (id = 1), + public_pem TEXT NOT NULL, + private_pem TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ap_followers ( + actor_uri TEXT PRIMARY KEY, + inbox_uri TEXT NOT NULL, + shared_inbox TEXT, + accepted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ap_peers ( + domain TEXT PRIMARY KEY, + actor_uri TEXT NOT NULL, + inbox_uri TEXT NOT NULL, + public_pem TEXT, + last_seen TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ap_outbox ( + id TEXT PRIMARY KEY, + activity TEXT NOT NULL, + published TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ap_outbox_pub ON ap_outbox(published DESC); + + CREATE TABLE IF NOT EXISTS ap_delivery_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inbox_uri TEXT NOT NULL, + payload TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_retry TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ap_dq_retry ON ap_delivery_queue(next_retry); + + CREATE TABLE IF NOT EXISTS ap_stats ( + domain TEXT NOT NULL, + direction TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + last_activity TEXT NOT NULL, + PRIMARY KEY (domain, direction) + ); + + CREATE TABLE IF NOT EXISTS ap_blocked ( + domain TEXT PRIMARY KEY, + reason TEXT, + blocked_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS server_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS passkeys ( + credential_id TEXT PRIMARY KEY, + session_token TEXT NOT NULL, + passkey_json TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'My passkey', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_passkeys_session ON passkeys(session_token); + + CREATE TABLE IF NOT EXISTS admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + target TEXT, + detail TEXT, + timestamp TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS admin_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + ").unwrap(); + + // federation column migrations + db.execute( + "ALTER TABLE community_clues ADD COLUMN origin_domain TEXT", + [], + ) + .ok(); + db.execute("ALTER TABLE community_clues ADD COLUMN origin_id TEXT", []) + .ok(); + db.execute("ALTER TABLE clue_votes ADD COLUMN origin_domain TEXT", []) + .ok(); + + // content reports + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS content_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_token TEXT NOT NULL, + content_type TEXT NOT NULL, + content_id TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ); + ", + ) + .unwrap(); + + // instance reputation + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS ap_instance_reputation ( + domain TEXT PRIMARY KEY, + score REAL NOT NULL DEFAULT 100.0, + total_clues INTEGER NOT NULL DEFAULT 0, + rejected_clues INTEGER NOT NULL DEFAULT 0, + reports_against INTEGER NOT NULL DEFAULT 0, + last_updated TEXT NOT NULL + ); + ", + ) + .unwrap(); + + // puzzle content hash + db.execute("ALTER TABLE puzzles ADD COLUMN content_hash TEXT", []) + .ok(); + + // federated leaderboard entries + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS federated_leaderboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin_domain TEXT NOT NULL, + puzzle_date TEXT NOT NULL, + preset TEXT NOT NULL, + player_name TEXT NOT NULL, + solve_time INTEGER NOT NULL, + received_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_fl_date ON federated_leaderboard(puzzle_date, preset); + ", + ) + .unwrap(); + + // cross-instance puzzle origin tracking + db.execute("ALTER TABLE user_puzzles ADD COLUMN origin_domain TEXT", []) + .ok(); + + // boost and reply tracking + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS ap_boosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + outbox_id TEXT NOT NULL, + actor_uri TEXT NOT NULL, + received_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ap_replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + outbox_id TEXT NOT NULL, + puzzle_id TEXT, + actor_uri TEXT NOT NULL, + actor_name TEXT, + content TEXT NOT NULL, + published TEXT, + received_at TEXT NOT NULL + ); + ", + ) + .unwrap(); + + // group actor followers for themed collections + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS ap_group_followers ( + group_name TEXT NOT NULL, + actor_uri TEXT NOT NULL, + inbox_uri TEXT NOT NULL, + accepted INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + PRIMARY KEY (group_name, actor_uri) + ); + ", + ) + .unwrap(); + + // wordlist packs + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS wordlist_packs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + author_session TEXT, + author_name TEXT, + tags TEXT, + word_count INTEGER NOT NULL, + download_count INTEGER DEFAULT 0, + rating_avg REAL, + rating_count INTEGER DEFAULT 0, + pack_data TEXT NOT NULL, + status TEXT DEFAULT 'published', + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pack_ratings ( + pack_id TEXT NOT NULL, + session_token TEXT NOT NULL, + rating INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (pack_id, session_token) + ); + ", + ) + .unwrap(); + + // api keys + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + session_token TEXT NOT NULL, + key_hash TEXT NOT NULL, + name TEXT NOT NULL, + permissions TEXT NOT NULL DEFAULT 'read', + rate_limit_per_min INTEGER DEFAULT 100, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT + ); + ", + ) + .unwrap(); + + // anti-cheat: flagged column on solve_records + db.execute( + "ALTER TABLE solve_records ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0", + [], + ) + .ok(); + + // anti-cheat: server-side hint tracking + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS puzzle_hints ( + puzzle_id TEXT NOT NULL, + session_token TEXT NOT NULL, + hint_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (puzzle_id, session_token) + ); + ", + ) + .unwrap(); + + // user banning + db.execute( + "ALTER TABLE sessions ADD COLUMN banned INTEGER DEFAULT 0", + [], + ) + .ok(); + + // session binding fingerprint + db.execute("ALTER TABLE sessions ADD COLUMN user_agent_hash TEXT", []).ok(); + + // solve receipts + db.execute_batch( + " + CREATE TABLE IF NOT EXISTS solve_receipts ( + receipt_id TEXT PRIMARY KEY, + puzzle_hash TEXT NOT NULL, + solve_time_secs INTEGER NOT NULL, + hints_used INTEGER NOT NULL DEFAULT 0, + solve_date TEXT NOT NULL, + instance_url TEXT NOT NULL, + player_name TEXT NOT NULL, + room_code TEXT, + mode TEXT, + players TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + signature TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_receipts_expire ON solve_receipts(expires_at); + + CREATE TABLE IF NOT EXISTS claimed_receipts ( + receipt_id TEXT PRIMARY KEY, + session_token TEXT NOT NULL, + origin_instance TEXT NOT NULL, + claimed_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS daily_challenges ( + date TEXT NOT NULL, + preset TEXT NOT NULL, + challenge_type TEXT NOT NULL, + PRIMARY KEY (date, preset) + ); + + CREATE TABLE IF NOT EXISTS challenge_completions ( + session_token TEXT NOT NULL, + date TEXT NOT NULL, + preset TEXT NOT NULL, + challenge_type TEXT NOT NULL, + completed_at TEXT NOT NULL, + PRIMARY KEY (session_token, date, preset) + ); + ", + ) + .unwrap(); + + // challenge completion provenance columns + db.execute("ALTER TABLE challenge_completions ADD COLUMN verified INTEGER NOT NULL DEFAULT 0", []) + .ok(); + db.execute("ALTER TABLE challenge_completions ADD COLUMN client_attested INTEGER NOT NULL DEFAULT 0", []) + .ok(); + + // analytics indexes + db.execute_batch( + " + CREATE INDEX IF NOT EXISTS idx_solve_completed ON solve_records(completed_at); + CREATE INDEX IF NOT EXISTS idx_solve_complete_flag ON solve_records(is_complete); + CREATE INDEX IF NOT EXISTS idx_sessions_last_seen ON sessions(last_seen); + CREATE INDEX IF NOT EXISTS idx_cc_status ON community_clues(status); + CREATE INDEX IF NOT EXISTS idx_reports_status ON content_reports(status); + CREATE INDEX IF NOT EXISTS idx_audit_action ON admin_audit_log(action); + ", + ) + .unwrap(); + + // migrations + let has_freezes: bool = db + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('streaks') WHERE name = 'freezes'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0; + if !has_freezes { + db.execute_batch("ALTER TABLE streaks ADD COLUMN freezes INTEGER NOT NULL DEFAULT 0") + .unwrap(); + } + + // cell results column for per-cell solve data + db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", []) + .ok(); + + db +} + +pub fn ban_session(db: &Connection, token: &str) { + db.execute("UPDATE sessions SET banned = 1 WHERE token = ?1", [token]) + .ok(); +} + +pub fn unban_session(db: &Connection, token: &str) { + db.execute("UPDATE sessions SET banned = 0 WHERE token = ?1", [token]) + .ok(); +} + +pub fn is_banned(db: &Connection, token: &str) -> bool { + db.query_row( + "SELECT banned FROM sessions WHERE token = ?1", + [token], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) + != 0 +} + +pub fn get_user_published_puzzles(db: &Connection, token: &str) -> Vec { + let mut stmt = db + .prepare("SELECT id FROM user_puzzles WHERE session_token = ?1 AND status = 'published'") + .unwrap(); + stmt.query_map([token], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn cleanup_user_content(db: &Connection, token: &str) { + db.execute("DELETE FROM user_puzzles WHERE session_token = ?1", [token]) + .ok(); + db.execute( + "DELETE FROM community_clues WHERE submitted_by = ?1", + [token], + ) + .ok(); + db.execute("DELETE FROM clue_votes WHERE session_token = ?1", [token]) + .ok(); + db.execute( + "DELETE FROM puzzle_ratings WHERE session_token = ?1", + [token], + ) + .ok(); + db.execute( + "UPDATE solve_records SET flagged = 1 WHERE session_token = ?1", + [token], + ) + .ok(); + db.execute("DELETE FROM pack_ratings WHERE session_token = ?1", [token]) + .ok(); + db.execute( + "DELETE FROM wordlist_packs WHERE author_session = ?1", + [token], + ) + .ok(); + db.execute( + "DELETE FROM content_reports WHERE reporter_token = ?1", + [token], + ) + .ok(); + // clear display name so deleted/banned users can't be looked up + db.execute( + "UPDATE sessions SET display_name = NULL WHERE token = ?1", + [token], + ) + .ok(); +} + +// api key management + +pub fn create_api_key( + db: &Connection, + id: &str, + session_token: &str, + key_hash: &str, + name: &str, + permissions: &str, +) -> bool { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO api_keys (id, session_token, key_hash, name, permissions, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, session_token, key_hash, name, permissions, now], + ).is_ok() +} + +pub fn get_api_key_by_hash( + db: &Connection, + key_hash: &str, +) -> Option<(String, String, String, i64, i64, Option)> { + db.query_row( + "SELECT id, session_token, permissions, rate_limit_per_min, is_active, expires_at FROM api_keys WHERE key_hash = ?1", + [key_hash], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)), + ).ok() +} + +pub fn list_api_keys( + db: &Connection, + session_token: &str, +) -> Vec<(String, String, String, String, i64, Option)> { + let mut stmt = db.prepare( + "SELECT id, name, permissions, created_at, is_active, last_used_at FROM api_keys WHERE session_token = ?1 ORDER BY created_at DESC" + ).unwrap(); + stmt.query_map([session_token], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn delete_api_key(db: &Connection, id: &str, session_token: &str) -> bool { + let rows = db + .execute( + "DELETE FROM api_keys WHERE id = ?1 AND session_token = ?2", + params![id, session_token], + ) + .unwrap_or(0); + rows > 0 +} + +pub fn touch_api_key(db: &Connection, id: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2", + params![now, id], + ) + .ok(); +} + +pub fn has_admin_account(db: &Connection) -> bool { + db.query_row("SELECT COUNT(*) FROM admin_accounts", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap_or(0) + > 0 +} + +pub fn create_admin_account(db: &Connection, username: &str, password_hash: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO admin_accounts (username, password_hash, created_at) VALUES (?1, ?2, ?3)", + params![username, password_hash, now], + ) + .ok(); +} + +pub fn verify_admin_account(db: &Connection, username: &str, password_hash: &str) -> bool { + let stored: Option = db + .query_row( + "SELECT password_hash FROM admin_accounts WHERE username = ?1", + [username], + |r| r.get(0), + ) + .ok(); + match stored { + Some(stored_hash) if stored_hash.starts_with("$argon2") => { + use argon2::PasswordVerifier; + let parsed = match argon2::PasswordHash::new(&stored_hash) { + Ok(p) => p, + Err(_) => return false, + }; + argon2::Argon2::default() + .verify_password(password_hash.as_bytes(), &parsed) + .is_ok() + } + Some(stored_hash) => { + // legacy salt:sha256 format - verify, and on success the caller + // upgrades the row via rehash_admin_password + let parts: Vec<&str> = stored_hash.splitn(2, ':').collect(); + if parts.len() != 2 { + return false; + } + let salt = parts[0]; + let expected = parts[1]; + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(salt.as_bytes()); + hasher.update(password_hash.as_bytes()); + let computed = hex::encode(hasher.finalize()); + computed.len() == expected.len() + && computed.bytes().zip(expected.bytes()).all(|(a, b)| a == b) + } + None => false, + } +} + +/// Called after a legacy hash verifies - swaps the stored row to argon2id. +pub fn rehash_admin_password(db: &Connection, username: &str, password: &str) { + use argon2::PasswordHasher; + let salt = + argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); + let hash = match argon2::Argon2::default().hash_password(password.as_bytes(), &salt) { + Ok(h) => h.to_string(), + Err(_) => return, + }; + db.execute( + "UPDATE admin_accounts SET password_hash = ?1 WHERE username = ?2", + params![hash, username], + ) + .ok(); +} + +pub fn log_admin_action(db: &Connection, action: &str, target: Option<&str>, detail: Option<&str>) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO admin_audit_log (action, target, detail, timestamp) VALUES (?1, ?2, ?3, ?4)", + params![action, target, detail, now], + ) + .ok(); +} + +pub fn get_audit_log( + db: &Connection, + limit: usize, +) -> Vec<(i64, String, Option, Option, String)> { + let mut stmt = db.prepare( + "SELECT id, action, target, detail, timestamp FROM admin_audit_log ORDER BY id DESC LIMIT ?1" + ).unwrap(); + stmt.query_map([limit as i64], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// short ID from UUID - base62 encode 60 bits so collisions stay negligible +pub fn make_short_id(uuid: &uuid::Uuid) -> String { + let bytes = uuid.as_bytes(); + let n = u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + ]) & 0x0FFF_FFFF_FFFF_FFFF; + base62_encode(n) +} + +fn base62_encode(mut n: u64) -> String { + const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + if n == 0 { + return "00000000000".into(); + } + let mut s = Vec::new(); + while n > 0 { + s.push(CHARS[(n % 62) as usize]); + n /= 62; + } + while s.len() < 11 { + s.push(b'0'); + } + s.reverse(); + String::from_utf8(s).unwrap() +} + +// puzzle storage +/// Stores a puzzle, allocating the short id with retries so a collision can +/// never silently replace the puzzle that already owns it. +pub fn store_puzzle( + db: &Connection, + id: &str, + puzzle_json: &str, + width: usize, + height: usize, + difficulty: u8, + daily_date: Option<&str>, + preset: Option<&str>, +) -> String { + let mut short_id = make_short_id(&uuid::Uuid::new_v4()); + for _ in 0..3 { + let now = chrono::Utc::now().to_rfc3339(); + let res = db.execute( + "INSERT INTO puzzles (id, short_id, daily_date, preset, width, height, difficulty, puzzle_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET + short_id = excluded.short_id, daily_date = excluded.daily_date, + preset = excluded.preset, width = excluded.width, height = excluded.height, + difficulty = excluded.difficulty, puzzle_json = excluded.puzzle_json, + created_at = excluded.created_at", + params![id, short_id, daily_date, preset, width as i64, height as i64, difficulty, puzzle_json, now], + ); + if res.is_ok() { + return short_id; + } + // unique-contraint hit on short_id - draw a new one and retry + short_id = make_short_id(&uuid::Uuid::new_v4()); + } + // three collisions in a row is effectively impossible at 60 bits; + // return the last id and let the caller surface any persistence error + short_id +} + +pub fn get_puzzle_json(db: &Connection, id: &str) -> Option<(String, String)> { + db.query_row( + "SELECT puzzle_json, short_id FROM puzzles WHERE id = ?1", + [id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .ok() +} + +pub fn get_puzzle_by_short_id(db: &Connection, short_id: &str) -> Option<(String, String)> { + db.query_row( + "SELECT id, puzzle_json FROM puzzles WHERE short_id = ?1", + [short_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .ok() +} + +pub fn get_daily_puzzle( + db: &Connection, + date: &str, + preset: &str, +) -> Option<(String, String, String)> { + db.query_row( + "SELECT id, short_id, puzzle_json FROM puzzles WHERE daily_date = ?1 AND preset = ?2", + params![date, preset], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .ok() +} + +pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String, String)> { + db.query_row( + "SELECT daily_date, preset FROM puzzles WHERE id = ?1 AND daily_date IS NOT NULL AND preset IS NOT NULL", + [puzzle_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ).ok() +} + +// sessions +pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option) { + let now = chrono::Utc::now().to_rfc3339(); + + let existing = db.query_row( + "SELECT token, display_name FROM sessions WHERE token = ?1", + [token], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ); + + if let Ok((t, name)) = existing { + db.execute( + "UPDATE sessions SET last_seen = ?1 WHERE token = ?2", + params![now, token], + ) + .ok(); + return (t, name); + } + + db.execute( + "INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)", + params![token, now], + ) + .unwrap(); + (token.to_string(), None) +} + +pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option { + db.query_row( + "SELECT user_agent_hash FROM sessions WHERE token = ?1", + [token], + |r| r.get(0), + ) + .ok() + .flatten() +} + +pub fn set_user_agent_hash(db: &Connection, token: &str, hash: &str) { + db.execute( + "UPDATE sessions SET user_agent_hash = ?1 WHERE token = ?2", + params![hash, token], + ) + .ok(); +} + +pub fn set_display_name(db: &Connection, token: &str, name: &str) { + db.execute( + "UPDATE sessions SET display_name = ?1 WHERE token = ?2", + params![name, token], + ) + .ok(); +} + +// solve records +pub fn record_solve( + db: &Connection, + token: &str, + puzzle_id: &str, + time_secs: i64, + hints: i64, + flagged: bool, + cell_results: Option<&str>, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT OR REPLACE INTO solve_records (session_token, puzzle_id, started_at, completed_at, solve_time_secs, hints_used, is_complete, flagged, cell_results) + VALUES (?1, ?2, ?3, ?3, ?4, ?5, 1, ?6, ?7)", + params![token, puzzle_id, now, time_secs, hints, flagged as i64, cell_results], + ).unwrap(); +} + +pub fn get_solve_info( + db: &Connection, + token: &str, + puzzle_id: &str, +) -> Option<(i64, i64, Option)> { + db.query_row( + "SELECT solve_time_secs, hints_used, cell_results FROM solve_records WHERE session_token = ?1 AND puzzle_id = ?2 AND is_complete = 1", + params![token, puzzle_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?)), + ).ok() +} + +pub fn has_solve_record(db: &Connection, token: &str, puzzle_id: &str) -> bool { + db.query_row( + "SELECT 1 FROM solve_records WHERE session_token = ?1 AND puzzle_id = ?2 AND is_complete = 1", + params![token, puzzle_id], + |_| Ok(()), + ).is_ok() +} + +pub fn increment_hint_count(db: &Connection, puzzle_id: &str, token: &str) { + db.execute( + "INSERT INTO puzzle_hints (puzzle_id, session_token, hint_count) + VALUES (?1, ?2, 1) + ON CONFLICT(puzzle_id, session_token) DO UPDATE SET hint_count = hint_count + 1", + params![puzzle_id, token], + ) + .unwrap(); +} + +pub fn get_hint_count(db: &Connection, puzzle_id: &str, token: &str) -> i64 { + db.query_row( + "SELECT hint_count FROM puzzle_hints WHERE puzzle_id = ?1 AND session_token = ?2", + params![puzzle_id, token], + |row| row.get(0), + ) + .unwrap_or(0) +} + +pub fn save_progress(db: &Connection, token: &str, puzzle_id: &str, grid_json: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO solve_records (session_token, puzzle_id, started_at, progress_json) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(session_token, puzzle_id) DO UPDATE SET progress_json = ?4", + params![token, puzzle_id, now, grid_json], + ) + .unwrap(); +} + +pub fn load_progress(db: &Connection, token: &str, puzzle_id: &str) -> Option { + db.query_row( + "SELECT progress_json FROM solve_records WHERE session_token = ?1 AND puzzle_id = ?2", + params![token, puzzle_id], + |row| row.get::<_, Option>(0), + ) + .ok() + .flatten() +} + +// streaks +pub fn get_streak(db: &Connection, token: &str) -> (i64, i64, Option) { + db.query_row( + "SELECT current_streak, best_streak, last_solve_date FROM streaks WHERE session_token = ?1", + [token], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .unwrap_or((0, 0, None)) +} + +pub fn get_streak_freezes(db: &Connection, token: &str) -> i64 { + db.query_row( + "SELECT freezes FROM streaks WHERE session_token = ?1", + [token], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) +} + +pub fn add_streak_freeze(db: &Connection, token: &str) { + db.execute( + "UPDATE streaks SET freezes = MIN(freezes + 1, 3) WHERE session_token = ?1", + [token], + ) + .ok(); +} + +pub fn use_streak_freeze(db: &Connection, token: &str) -> bool { + let freezes = get_streak_freezes(db, token); + if freezes > 0 { + db.execute( + "UPDATE streaks SET freezes = freezes - 1 WHERE session_token = ?1", + [token], + ) + .ok(); + true + } else { + false + } +} + +pub fn update_streak(db: &Connection, token: &str, today: &str) { + let (current, best, last_date) = get_streak(db, token); + + let new_current = if let Some(last) = &last_date { + if last == today { + return; // already solved today + } + let last_d = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").unwrap_or_default(); + let today_d = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d").unwrap_or_default(); + let diff = (today_d - last_d).num_days(); + if diff == 1 { + current + 1 + } else if diff == 2 && use_streak_freeze(db, token) { + // missed exactly one day, use a freeze to keep the streak + current + 1 + } else { + 1 + } + } else { + 1 + }; + + let new_best = new_current.max(best); + + db.execute( + "INSERT INTO streaks (session_token, current_streak, best_streak, last_solve_date) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(session_token) DO UPDATE SET current_streak = ?2, best_streak = ?3, last_solve_date = ?4", + params![token, new_current, new_best, today], + ).unwrap(); + + // award a freeze for every 7-day streak milestone + if new_current > 0 && new_current % 7 == 0 { + add_streak_freeze(db, token); + } +} + +// stats +pub fn get_stats(db: &Connection, token: &str) -> Vec<(String, String, i64, i64)> { + let mut stmt = db + .prepare( + "SELECT sr.puzzle_id, p.preset, sr.solve_time_secs, sr.hints_used + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 + ORDER BY sr.completed_at DESC + LIMIT 50", + ) + .unwrap(); + + stmt.query_map([token], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?.unwrap_or_default(), + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// community clues +pub fn submit_clue(db: &Connection, word: &str, clue_text: &str, token: &str) -> i64 { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO community_clues (word, clue_text, submitted_by, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![word.to_uppercase(), clue_text, token, now], + ) + .unwrap(); + db.last_insert_rowid() +} + +pub fn get_community_clues( + db: &Connection, + word: &str, +) -> Vec<(i64, String, i64, i64, String, String, Option)> { + let mut stmt = db.prepare( + "SELECT cc.id, cc.clue_text, cc.upvotes, cc.downvotes, cc.status, cc.created_at, cc.origin_domain + FROM community_clues cc WHERE cc.word = ?1 + ORDER BY (cc.upvotes - cc.downvotes) DESC, cc.created_at DESC" + ).unwrap(); + stmt.query_map([word.to_uppercase()], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool { + // check if already voted + let existing: Option = db + .query_row( + "SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2", + params![token, clue_id], + |row| row.get(0), + ) + .ok(); + + if let Some(old_vote) = existing { + if old_vote == vote { + return false; + } // same vote, no change + // undo old vote + if old_vote > 0 { + db.execute( + "UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1", + [clue_id], + ) + .ok(); + } else { + db.execute( + "UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1", + [clue_id], + ) + .ok(); + } + // apply new vote + if vote > 0 { + db.execute( + "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1", + [clue_id], + ) + .ok(); + } else { + db.execute( + "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1", + [clue_id], + ) + .ok(); + } + db.execute( + "UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3", + params![vote, token, clue_id], + ) + .ok(); + } else { + // new vote + if vote > 0 { + db.execute( + "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1", + [clue_id], + ) + .ok(); + } else { + db.execute( + "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1", + [clue_id], + ) + .ok(); + } + db.execute( + "INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)", + params![token, clue_id, vote], + ) + .ok(); + } + + // auto-approve clues with net +3 votes, auto-reject at net -3 + let (up, down): (i64, i64) = db + .query_row( + "SELECT upvotes, downvotes FROM community_clues WHERE id = ?1", + [clue_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap_or((0, 0)); + + if up - down >= 3 { + db.execute( + "UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'", + [clue_id], + ) + .ok(); + } else if down - up >= 3 { + db.execute( + "UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'", + [clue_id], + ) + .ok(); + } + + true +} + +pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> { + if clue_ids.is_empty() { + return vec![]; + } + let placeholders: Vec = clue_ids.iter().map(|_| "?".into()).collect(); + let sql = format!( + "SELECT clue_id, vote FROM clue_votes WHERE session_token = ?1 AND clue_id IN ({})", + placeholders.join(",") + ); + let mut stmt = db.prepare(&sql).unwrap(); + let mut params_vec: Vec> = vec![Box::new(token.to_string())]; + for id in clue_ids { + params_vec.push(Box::new(*id)); + } + stmt.query_map(rusqlite::params_from_iter(params_vec), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?)) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn get_my_submissions( + db: &Connection, + token: &str, +) -> Vec<(i64, String, String, i64, i64, String)> { + let mut stmt = db + .prepare( + "SELECT id, word, clue_text, upvotes, downvotes, status + FROM community_clues WHERE submitted_by = ?1 + ORDER BY created_at DESC LIMIT 50", + ) + .unwrap(); + stmt.query_map([token], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// puzzle ratings +pub fn rate_puzzle(db: &Connection, token: &str, puzzle_id: &str, rating: i32) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO puzzle_ratings (session_token, puzzle_id, rating, created_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(session_token, puzzle_id) DO UPDATE SET rating = ?3", + params![token, puzzle_id, rating, now], + ) + .unwrap(); +} + +pub fn get_puzzle_rating(db: &Connection, puzzle_id: &str) -> (f64, i64) { + db.query_row( + "SELECT COALESCE(AVG(CAST(rating AS REAL)), 0), COUNT(*) FROM puzzle_ratings WHERE puzzle_id = ?1", + [puzzle_id], + |row| Ok((row.get::<_, f64>(0)?, row.get::<_, i64>(1)?)), + ).unwrap_or((0.0, 0)) +} + +/// Apply a rating change to word and clue quality aggregates. +/// delta is the change in the user's rating (new minus old) and first flags +/// whether this is a brand new rating, in which case the count increments. +pub fn update_quality_from_rating(db: &Connection, puzzle_id: &str, delta: i64, first: bool) { + let json = match db.query_row( + "SELECT puzzle_json FROM puzzles WHERE id = ?1", + [puzzle_id], + |row| row.get::<_, String>(0), + ) { + Ok(j) => j, + Err(_) => return, + }; + + let puzzle: serde_json::Value = match serde_json::from_str(&json) { + Ok(v) => v, + Err(_) => return, + }; + + let count_change: i64 = if first { 1 } else { 0 }; + + // extract word-clue pairs from both across and down + for key in ["clues_across", "clues_down"] { + if let Some(clues) = puzzle.get(key).and_then(|v| v.as_array()) { + for clue in clues { + let word = clue.get("answer").and_then(|v| v.as_str()).unwrap_or(""); + let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if word.is_empty() || text.is_empty() { + continue; + } + + db.execute( + "INSERT INTO clue_quality (word, clue_text, total_score, rating_count) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(word, clue_text) DO UPDATE SET + total_score = MAX(0, total_score + ?3), rating_count = rating_count + ?4", + params![word, text, delta, count_change], + ) + .ok(); + + db.execute( + "INSERT INTO word_quality (word, total_score, rating_count) + VALUES (?1, ?2, ?3) + ON CONFLICT(word) DO UPDATE SET + total_score = MAX(0, total_score + ?2), rating_count = rating_count + ?3", + params![word, delta, count_change], + ) + .ok(); + } + } + } +} + +pub fn get_daily_ratings(db: &Connection, date: &str) -> Vec<(String, f64, i64)> { + let mut stmt = db + .prepare( + "SELECT p.preset, COALESCE(AVG(CAST(pr.rating AS REAL)), 0), COUNT(pr.rating) + FROM puzzles p LEFT JOIN puzzle_ratings pr ON p.id = pr.puzzle_id + WHERE p.daily_date = ?1 AND p.preset IS NOT NULL + GROUP BY p.preset", + ) + .unwrap(); + stmt.query_map([date], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, f64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn get_preferred_preset(db: &Connection, token: &str) -> Option { + db.query_row( + "SELECT p.preset FROM puzzle_ratings pr + JOIN puzzles p ON pr.puzzle_id = p.id + WHERE pr.session_token = ?1 AND p.preset IS NOT NULL AND pr.rating >= 4 + GROUP BY p.preset ORDER BY AVG(CAST(pr.rating AS REAL)) DESC, COUNT(*) DESC + LIMIT 1", + [token], + |row| row.get(0), + ) + .ok() +} + +pub fn get_clue_quality_scores( + db: &Connection, +) -> std::collections::HashMap<(String, String), f64> { + let mut result = std::collections::HashMap::new(); + if let Ok(mut stmt) = db.prepare( + "SELECT word, clue_text, CAST(total_score AS REAL) / rating_count + FROM clue_quality WHERE rating_count >= 3", + ) { + if let Ok(rows) = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + )) + }) { + for r in rows.flatten() { + result.insert((r.0, r.1), r.2); + } + } + } + result +} + +pub fn get_word_quality_scores(db: &Connection) -> std::collections::HashMap { + let mut result = std::collections::HashMap::new(); + if let Ok(mut stmt) = db.prepare( + "SELECT word, CAST(total_score AS REAL) / rating_count + FROM word_quality WHERE rating_count >= 3", + ) { + if let Ok(rows) = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) { + for r in rows.flatten() { + result.insert(r.0, r.1); + } + } + } + result +} + +pub fn get_my_rating(db: &Connection, token: &str, puzzle_id: &str) -> Option { + db.query_row( + "SELECT rating FROM puzzle_ratings WHERE session_token = ?1 AND puzzle_id = ?2", + params![token, puzzle_id], + |row| row.get(0), + ) + .ok() +} + +pub fn delete_submission(db: &Connection, id: i64, token: &str) { + db.execute( + "DELETE FROM community_clues WHERE id = ?1 AND submitted_by = ?2", + params![id, token], + ) + .ok(); + db.execute("DELETE FROM clue_votes WHERE clue_id = ?1", [id]) + .ok(); +} + +// user puzzles (editor) +pub fn save_user_puzzle( + db: &Connection, + id: &str, + token: &str, + title: &str, + width: usize, + height: usize, + grid_json: &str, + clues_json: Option<&str>, + puzzle_json: Option<&str>, + status: &str, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO user_puzzles (id, session_token, title, width, height, grid_json, clues_json, puzzle_json, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10) + ON CONFLICT(id) DO UPDATE SET title=?3, grid_json=?6, clues_json=?7, puzzle_json=?8, status=?9, updated_at=?10", + params![id, token, title, width as i64, height as i64, grid_json, clues_json, puzzle_json, status, now], + ).unwrap(); +} + +pub fn get_user_puzzle( + db: &Connection, + id: &str, + token: &str, +) -> Option<( + String, + String, + i64, + i64, + String, + Option, + Option, + String, +)> { + db.query_row( + "SELECT id, title, width, height, grid_json, clues_json, puzzle_json, status + FROM user_puzzles WHERE id = ?1 AND session_token = ?2", + params![id, token], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + ) + .ok() +} + +pub fn list_user_puzzles( + db: &Connection, + token: &str, +) -> Vec<(String, String, i64, i64, String, String)> { + let mut stmt = db + .prepare( + "SELECT id, title, width, height, status, updated_at FROM user_puzzles + WHERE session_token = ?1 ORDER BY updated_at DESC LIMIT 50", + ) + .unwrap(); + stmt.query_map([token], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn delete_user_puzzle(db: &Connection, id: &str, token: &str) { + db.execute( + "DELETE FROM user_puzzles WHERE id = ?1 AND session_token = ?2", + params![id, token], + ) + .ok(); +} + +pub fn publish_user_puzzle(db: &Connection, id: &str, token: &str) -> bool { + let affected = db + .execute( + "UPDATE user_puzzles SET status = 'published', updated_at = ?3 + WHERE id = ?1 AND session_token = ?2 AND clues_json IS NOT NULL", + params![id, token, chrono::Utc::now().to_rfc3339()], + ) + .unwrap_or(0); + affected > 0 +} + +pub fn list_published_puzzles( + db: &Connection, + limit: usize, + offset: usize, +) -> Vec<(String, String, String, i64, i64, String, Option)> { + let mut stmt = db.prepare( + "SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain + FROM user_puzzles up + LEFT JOIN sessions s ON up.session_token = s.token + LEFT JOIN settings st ON up.session_token = st.session_token + WHERE up.status = 'published' AND up.clues_json IS NOT NULL + ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2" + ).unwrap(); + stmt.query_map(params![limit as i64, offset as i64], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get::<_, Option>(2)? + .unwrap_or_else(|| "Anonymous".into()), + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn list_federated_puzzles( + db: &Connection, + limit: usize, + offset: usize, +) -> Vec<(String, String, i64, i64, String, String)> { + let mut stmt = db + .prepare( + "SELECT id, title, width, height, origin_domain, updated_at + FROM user_puzzles + WHERE session_token = 'federation' AND status = 'published' AND clues_json IS NOT NULL + ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2", + ) + .unwrap(); + stmt.query_map(params![limit as i64, offset as i64], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get::<_, String>(4)?, + row.get(5)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn get_published_puzzle( + db: &Connection, + id: &str, +) -> Option<(String, i64, i64, String, String)> { + db.query_row( + "SELECT title, width, height, grid_json, clues_json + FROM user_puzzles WHERE id = ?1 AND status = 'published' AND clues_json IS NOT NULL", + [id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .ok() +} + +// webhooks +pub fn create_webhook( + db: &Connection, + id: &str, + url: &str, + events: &str, + secret: Option<&str>, + format: &str, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO webhooks (id, url, events, secret, format, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, url, events, secret, format, now], + ).unwrap(); +} + +pub fn list_webhooks( + db: &Connection, +) -> Vec<(String, String, String, Option, String, bool, i64)> { + let mut stmt = db.prepare( + "SELECT id, url, events, secret, format, enabled, failure_count FROM webhooks ORDER BY created_at DESC" + ).unwrap(); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get::<_, i64>(5)? != 0, + row.get(6)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn delete_webhook(db: &Connection, id: &str) { + db.execute("DELETE FROM webhooks WHERE id = ?1", [id]).ok(); +} + +pub fn get_enabled_webhooks( + db: &Connection, + event: &str, +) -> Vec<(String, String, Option, String)> { + let mut stmt = db.prepare( + "SELECT id, url, secret, format FROM webhooks WHERE enabled = 1 AND events LIKE '%' || ?1 || '%'" + ).unwrap(); + stmt.query_map([event], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn record_webhook_failure(db: &Connection, id: &str) { + db.execute( + "UPDATE webhooks SET failure_count = failure_count + 1 WHERE id = ?1", + [id], + ) + .ok(); +} + +// federation stats +pub fn track_federation_activity(db: &Connection, domain: &str, direction: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO ap_stats (domain, direction, count, last_activity) VALUES (?1, ?2, 1, ?3) + ON CONFLICT(domain, direction) DO UPDATE SET count = count + 1, last_activity = ?3", + params![domain, direction, now], + ) + .ok(); +} + +// server config (DB overrides env vars) +pub fn get_server_config(db: &Connection, key: &str) -> Option { + db.query_row( + "SELECT value FROM server_config WHERE key = ?1", + [key], + |row| row.get(0), + ) + .ok() +} + +pub fn set_server_config(db: &Connection, key: &str, value: &str) { + db.execute( + "INSERT INTO server_config (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2", + params![key, value], + ).ok(); +} + +pub fn get_config_or_env(db: &Connection, key: &str, default: &str) -> String { + get_server_config(db, key) + .or_else(|| std::env::var(key).ok()) + .unwrap_or_else(|| default.into()) +} + +pub fn get_config_bool(db: &Connection, key: &str) -> bool { + get_config_or_env(db, key, "false") == "true" +} + +pub fn get_all_config(db: &Connection) -> serde_json::Value { + serde_json::json!({ + "CORS_ORIGIN": get_config_or_env(db, "CORS_ORIGIN", ""), + "SECURE_COOKIES": get_config_bool(db, "SECURE_COOKIES"), + "HSTS": get_config_bool(db, "HSTS"), + "PUBLIC_MOD_LOG": get_config_bool(db, "PUBLIC_MOD_LOG"), + "INSTANCE_CONTACT": get_config_or_env(db, "INSTANCE_CONTACT", ""), + "INSTANCE_RULES": get_config_or_env(db, "INSTANCE_RULES", "Be respectful. Submit quality clues. No spam."), + "AP_DOMAIN": std::env::var("AP_DOMAIN").unwrap_or_default(), + "AP_ACTOR_NAME": get_config_or_env(db, "AP_ACTOR_NAME", "cruciverb"), + "ap_actor_name": get_config_or_env(db, "ap_actor_name", "crossword"), + "ap_bio": get_config_or_env(db, "ap_bio", ""), + "ap_avatar_url": get_config_or_env(db, "ap_avatar_url", ""), + "ap_banner_url": get_config_or_env(db, "ap_banner_url", ""), + "instance_rules": get_config_or_env(db, "instance_rules", "Be respectful. Submit quality clues. No spam."), + "AP_ALLOWLIST_MODE": get_config_bool(db, "AP_ALLOWLIST_MODE"), + "AP_APPROVE_FOLLOWERS": get_config_bool(db, "AP_APPROVE_FOLLOWERS"), + "AP_NEW_PEER_REVIEW": get_config_bool(db, "AP_NEW_PEER_REVIEW"), + "AP_OUTBOX_LIMIT": get_config_or_env(db, "AP_OUTBOX_LIMIT", "20").parse::().unwrap_or(20), + "AP_SHARED_BLOCKLIST": get_config_bool(db, "AP_SHARED_BLOCKLIST"), + "ADMIN_PATH": get_server_config(db, "admin_path") + .unwrap_or_else(|| std::env::var("ADMIN_PATH").unwrap_or_else(|_| "/admin".into())), + "ap_daily_taglines": get_server_config(db, "ap_daily_taglines") + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or(serde_json::json!([])), + "ap_daily_signoffs": get_server_config(db, "ap_daily_signoffs") + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or(serde_json::json!([])), + "ap_community_middles": get_server_config(db, "ap_community_middles") + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or(serde_json::json!([])), + }) +} + +// settings +pub fn get_settings(db: &Connection, token: &str) -> String { + db.query_row( + "SELECT settings_json FROM settings WHERE session_token = ?1", + [token], + |row| row.get::<_, String>(0), + ) + .unwrap_or_else(|_| "{}".into()) +} + +pub fn save_settings(db: &Connection, token: &str, settings_json: &str) { + db.execute( + "INSERT INTO settings (session_token, settings_json) VALUES (?1, ?2) + ON CONFLICT(session_token) DO UPDATE SET settings_json = ?2", + params![token, settings_json], + ) + .unwrap(); +} + +// passkeys +pub fn has_passkey(db: &Connection, token: &str) -> bool { + db.query_row( + "SELECT COUNT(*) FROM passkeys WHERE session_token = ?1", + [token], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0 +} + +pub fn get_passkeys(db: &Connection, token: &str) -> Vec<(String, String, String)> { + let mut stmt = db.prepare( + "SELECT credential_id, name, created_at FROM passkeys WHERE session_token = ?1 ORDER BY created_at DESC" + ).unwrap(); + stmt.query_map([token], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn save_passkey( + db: &Connection, + credential_id: &str, + token: &str, + passkey_json: &str, + name: &str, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO passkeys (credential_id, session_token, passkey_json, name, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![credential_id, token, passkey_json, name, now], + ) + .unwrap(); +} + +pub fn update_passkey_json(db: &Connection, credential_id: &str, passkey_json: &str) { + db.execute( + "UPDATE passkeys SET passkey_json = ?1 WHERE credential_id = ?2", + params![passkey_json, credential_id], + ) + .ok(); +} + +pub fn get_passkey_json(db: &Connection, credential_id: &str) -> Option<(String, String)> { + db.query_row( + "SELECT passkey_json, session_token FROM passkeys WHERE credential_id = ?1", + [credential_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .ok() +} + +// achievements +pub fn get_achievements(db: &Connection, token: &str) -> Vec<(String, String)> { + let mut stmt = db.prepare( + "SELECT achievement_id, unlocked_at FROM achievements WHERE session_token = ?1 ORDER BY unlocked_at DESC" + ).unwrap(); + stmt.query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn unlock_achievement(db: &Connection, token: &str, achievement_id: &str) -> bool { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT OR IGNORE INTO achievements (session_token, achievement_id, unlocked_at) VALUES (?1, ?2, ?3)", + params![token, achievement_id, now], + ).unwrap() > 0 // returns true if newly inserted +} + +pub fn get_solve_count(db: &Connection, token: &str) -> i64 { + db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1", + [token], + |row| row.get(0), + ) + .unwrap_or(0) +} + +pub fn get_solve_count_by_preset(db: &Connection, token: &str, preset: &str) -> i64 { + db.query_row( + "SELECT COUNT(*) FROM solve_records sr JOIN puzzles p ON sr.puzzle_id = p.id WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND p.preset = ?2", + params![token, preset], |row| row.get(0), + ).unwrap_or(0) +} + +pub fn get_no_hint_count(db: &Connection, token: &str) -> i64 { + db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND hints_used = 0", + [token], |row| row.get(0), + ).unwrap_or(0) +} + +pub fn get_fastest_solve(db: &Connection, token: &str, preset: &str) -> Option { + db.query_row( + "SELECT MIN(sr.solve_time_secs) FROM solve_records sr JOIN puzzles p ON sr.puzzle_id = p.id WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND p.preset = ?2", + params![token, preset], |row| row.get::<_, Option>(0), + ).ok().flatten() +} + +// content reports +pub fn submit_report( + db: &Connection, + token: &str, + content_type: &str, + content_id: &str, + reason: Option<&str>, +) -> i64 { + let existing: bool = db.query_row( + "SELECT COUNT(*) > 0 FROM content_reports WHERE reporter_token = ?1 AND content_type = ?2 AND content_id = ?3", + params![token, content_type, content_id], |r| r.get(0), + ).unwrap_or(false); + if existing { + return 0; + } + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO content_reports (reporter_token, content_type, content_id, reason, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![token, content_type, content_id, reason, now], + ) + .unwrap(); + db.last_insert_rowid() +} + +pub fn get_pending_reports( + db: &Connection, +) -> Vec<(i64, String, String, String, Option, String)> { + let mut stmt = db + .prepare( + "SELECT id, reporter_token, content_type, content_id, reason, created_at + FROM content_reports WHERE status = 'pending' ORDER BY created_at DESC LIMIT 100", + ) + .unwrap(); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn resolve_report(db: &Connection, id: i64, status: &str) { + db.execute( + "UPDATE content_reports SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .ok(); +} + +// instance reputation +pub fn update_instance_reputation(db: &Connection, domain: &str, delta: f64) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO ap_instance_reputation (domain, score, last_updated) VALUES (?1, ?2, ?3) + ON CONFLICT(domain) DO UPDATE SET score = MAX(0, MIN(100, score + ?4)), last_updated = ?3", + params![domain, 100.0 + delta, now, delta], + ) + .ok(); + // track totals + if delta < 0.0 { + db.execute( + "UPDATE ap_instance_reputation SET rejected_clues = rejected_clues + 1 WHERE domain = ?1", + [domain], + ).ok(); + } else { + db.execute( + "UPDATE ap_instance_reputation SET total_clues = total_clues + 1 WHERE domain = ?1", + [domain], + ) + .ok(); + } +} + +pub fn increment_instance_reports(db: &Connection, domain: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO ap_instance_reputation (domain, score, reports_against, last_updated) + VALUES (?1, 90.0, 1, ?2) + ON CONFLICT(domain) DO UPDATE SET + score = MAX(0, score - 10), reports_against = reports_against + 1, last_updated = ?2", + params![domain, now], + ) + .ok(); +} + +pub fn get_instance_reputation(db: &Connection, domain: &str) -> f64 { + db.query_row( + "SELECT score FROM ap_instance_reputation WHERE domain = ?1", + [domain], + |row| row.get(0), + ) + .unwrap_or(100.0) +} + +pub fn get_all_instance_reputations(db: &Connection) -> Vec<(String, f64, i64, i64, i64, String)> { + let mut stmt = db + .prepare( + "SELECT domain, score, total_clues, rejected_clues, reports_against, last_updated + FROM ap_instance_reputation ORDER BY score ASC", + ) + .unwrap(); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// import user data from export +pub fn import_user_data( + db: &Connection, + token: &str, + data: &serde_json::Value, +) -> (usize, Vec) { + let mut imported = 0; + let mut skipped = Vec::new(); + + // display name + if let Some(name) = data + .get("session") + .and_then(|s| s.get("display_name")) + .and_then(|v| v.as_str()) + { + set_display_name(db, token, name); + imported += 1; + } + + // settings + if let Some(settings) = data.get("settings") { + save_settings(db, token, &settings.to_string()); + imported += 1; + } + + // streak (only if better than current) + if let Some(streak) = data.get("streak") { + let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0); + let imp_current = streak.get("current").and_then(|v| v.as_i64()).unwrap_or(0); + let imp_last = streak + .get("last_date") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let (cur_current, cur_best, _) = get_streak(db, token); + if imp_best > cur_best as i64 || imp_current > cur_current as i64 { + db.execute( + "INSERT OR REPLACE INTO streaks (session_token, current_streak, best_streak, last_solve_date) VALUES (?1, ?2, ?3, ?4)", + params![token, std::cmp::max(imp_current, cur_current as i64), std::cmp::max(imp_best, cur_best as i64), imp_last], + ).ok(); + imported += 1; + } + } + + // achievements (merge - only add ones not already unlocked) + if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) { + for ach in achievements { + let id = ach.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let unlocked_at = ach + .get("unlocked_at") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if id.is_empty() { + continue; + } + db.execute( + "INSERT OR IGNORE INTO achievements (session_token, achievement_id, unlocked_at) VALUES (?1, ?2, ?3)", + params![token, id, unlocked_at], + ).ok(); + imported += 1; + } + } + + // community clue submissions (re-associate with new token) + if let Some(subs) = data.get("submissions").and_then(|v| v.as_array()) { + for sub in subs { + let word = sub.get("word").and_then(|v| v.as_str()).unwrap_or(""); + let clue = sub.get("clue").and_then(|v| v.as_str()).unwrap_or(""); + if word.is_empty() || clue.is_empty() { + continue; + } + // check for URL in clue text + if text_contains_url(clue) { + skipped.push(format!("clue for {}: contains URL", word)); + continue; + } + // check duplicate + let exists: bool = db + .query_row( + "SELECT COUNT(*) > 0 FROM community_clues WHERE word = ?1 AND clue_text = ?2", + params![word, clue], + |r| r.get(0), + ) + .unwrap_or(false); + if exists { + skipped.push(format!("clue for {}: already exists", word)); + continue; + } + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO community_clues (word, clue_text, submitted_by, status, created_at) VALUES (?1, ?2, ?3, 'pending', ?4)", + params![word, clue, token, now], + ).ok(); + imported += 1; + } + } + + // solve records are skipped - puzzle IDs may not exist on this instance + if data + .get("solves") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + > 0 + { + skipped.push("solve records: puzzle IDs are instance-specific".into()); + } + // ratings skipped for same reason + if data + .get("ratings") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + > 0 + { + skipped.push("puzzle ratings: puzzle IDs are instance-specific".into()); + } + // user puzzles - import if they have full grid data + if let Some(puzzles) = data.get("puzzles").and_then(|v| v.as_array()) { + for puzzle in puzzles { + let title = puzzle + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Imported"); + let w = puzzle.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let h = puzzle.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let grid = puzzle.get("grid"); + if w == 0 || h == 0 || grid.is_none() { + skipped.push(format!("puzzle '{}': missing grid data", title)); + continue; + } + let grid_json = serde_json::to_string(grid.unwrap()).unwrap_or_default(); + let clues_json = puzzle + .get("clues") + .map(|c| serde_json::to_string(c).unwrap_or_default()); + let id = uuid::Uuid::new_v4().to_string(); + save_user_puzzle( + db, + &id, + token, + title, + w, + h, + &grid_json, + clues_json.as_deref(), + None, + "draft", + ); + imported += 1; + } + } + + (imported, skipped) +} + +// auto-purge old records +pub fn auto_purge(db: &Connection, token: &str, days: i64) { + let cutoff = chrono::Utc::now() - chrono::Duration::days(days); + let cutoff_str = cutoff.to_rfc3339(); + db.execute( + "DELETE FROM solve_records WHERE session_token = ?1 AND completed_at < ?2", + params![token, cutoff_str], + ) + .ok(); +} + +// delete all user data +pub fn delete_account(db: &Connection, token: &str) { + db.execute( + "DELETE FROM solve_records WHERE session_token = ?1", + [token], + ) + .ok(); + db.execute("DELETE FROM streaks WHERE session_token = ?1", [token]) + .ok(); + db.execute("DELETE FROM achievements WHERE session_token = ?1", [token]) + .ok(); + db.execute("DELETE FROM settings WHERE session_token = ?1", [token]) + .ok(); + db.execute( + "DELETE FROM community_clues WHERE submitted_by = ?1", + [token], + ) + .ok(); + db.execute("DELETE FROM clue_votes WHERE session_token = ?1", [token]) + .ok(); + db.execute( + "DELETE FROM puzzle_ratings WHERE session_token = ?1", + [token], + ) + .ok(); + db.execute("DELETE FROM user_puzzles WHERE session_token = ?1", [token]) + .ok(); + db.execute("DELETE FROM passkeys WHERE session_token = ?1", [token]) + .ok(); + db.execute( + "DELETE FROM content_reports WHERE reporter_token = ?1", + [token], + ) + .ok(); + db.execute("DELETE FROM sessions WHERE token = ?1", [token]) + .ok(); +} + +// export all user data +pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value { + let session: serde_json::Value = db + .query_row( + "SELECT display_name, created_at, last_seen FROM sessions WHERE token = ?1", + [token], + |row| { + Ok(serde_json::json!({ + "display_name": row.get::<_, Option>(0)?, + "created_at": row.get::<_, String>(1)?, + "last_seen": row.get::<_, String>(2)?, + })) + }, + ) + .unwrap_or(serde_json::json!(null)); + + let settings = get_settings(db, token); + let solves = get_stats(db, token); + let (current, best, last) = get_streak(db, token); + let achievements = get_achievements(db, token); + let submissions = get_my_submissions(db, token); + + let mut ratings = Vec::new(); + if let Ok(mut stmt) = db.prepare( + "SELECT puzzle_id, rating, created_at FROM puzzle_ratings WHERE session_token = ?1", + ) { + if let Ok(rows) = stmt.query_map([token], |row| { + Ok(serde_json::json!({ + "puzzle_id": row.get::<_, String>(0)?, + "rating": row.get::<_, i32>(1)?, + "created_at": row.get::<_, String>(2)?, + })) + }) { + for r in rows.flatten() { + ratings.push(r); + } + } + } + + let full_puzzles = export_user_puzzles(db, token); + + serde_json::json!({ + "session": session, + "settings": serde_json::from_str::(&settings).unwrap_or_default(), + "solves": solves.iter().map(|(pid, preset, time, hints)| { + serde_json::json!({"puzzle_id": pid, "preset": preset, "time": time, "hints": hints}) + }).collect::>(), + "streak": {"current": current, "best": best, "last_date": last}, + "achievements": achievements.iter().map(|(id, t)| { + serde_json::json!({"id": id, "unlocked_at": t}) + }).collect::>(), + "submissions": submissions.iter().map(|(id, word, clue, up, down, status)| { + serde_json::json!({"id": id, "word": word, "clue": clue, "upvotes": up, "downvotes": down, "status": status}) + }).collect::>(), + "ratings": ratings, + "puzzles": full_puzzles, + }) +} + +fn export_user_puzzles(db: &Connection, token: &str) -> Vec { + let mut result = Vec::new(); + if let Ok(mut stmt) = db.prepare( + "SELECT id, title, width, height, grid_json, clues_json, status, updated_at + FROM user_puzzles WHERE session_token = ?1 ORDER BY updated_at DESC", + ) { + if let Ok(rows) = stmt.query_map([token], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, String>(0)?, + "title": row.get::<_, String>(1)?, + "width": row.get::<_, i64>(2)?, + "height": row.get::<_, i64>(3)?, + "grid": serde_json::from_str::(&row.get::<_, String>(4)?).unwrap_or_default(), + "clues": row.get::<_, Option>(5)?.and_then(|s| serde_json::from_str::(&s).ok()), + "status": row.get::<_, String>(6)?, + "updated_at": row.get::<_, String>(7)?, + })) + }) { + for r in rows.flatten() { result.push(r); } + } + } + result +} + +// privacy-aware leaderboards - exclude users with hideFromLeaderboards +pub fn get_leaderboard_filtered( + db: &Connection, + puzzle_id: &str, + limit: usize, +) -> Vec<(String, i64, i64)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + ORDER BY sr.solve_time_secs ASC + LIMIT ?2" + ).unwrap(); + + stmt.query_map(params![puzzle_id, limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +pub fn get_daily_leaderboard_filtered( + db: &Connection, + date: &str, + preset: &str, + limit: usize, +) -> Vec<(String, i64, i64)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + ORDER BY sr.solve_time_secs ASC + LIMIT ?3" + ).unwrap(); + + stmt.query_map(params![date, preset, limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// count recent federated clues from a domain (last hour) +pub fn count_recent_clues_from_domain(db: &Connection, domain: &str) -> i64 { + let cutoff = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339(); + db.query_row( + "SELECT COUNT(*) FROM community_clues WHERE origin_domain = ?1 AND created_at > ?2", + params![domain, cutoff], + |r| r.get(0), + ) + .unwrap_or(0) +} + +// count consecutive rejected clues from a domain +pub fn count_consecutive_rejections(db: &Connection, domain: &str) -> i64 { + let mut stmt = db + .prepare( + "SELECT status FROM community_clues WHERE origin_domain = ?1 ORDER BY id DESC LIMIT 10", + ) + .unwrap(); + let statuses: Vec = stmt + .query_map([domain], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + let mut count = 0i64; + for s in &statuses { + if s == "rejected" { + count += 1; + } else { + break; + } + } + count +} + +// puzzle content hash +pub fn store_puzzle_hash(db: &Connection, id: &str, hash: &str) { + db.execute( + "UPDATE puzzles SET content_hash = ?1 WHERE id = ?2", + params![hash, id], + ) + .ok(); +} + +// check for URLs in text +pub fn text_contains_url(text: &str) -> bool { + text.contains("http://") || text.contains("https://") || text.contains("www.") +} + +// #10: ephemeral session cleanup - delete data for sessions with ephemeral=true that expired +pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 { + // find sessions that have ephemeral=true and haven't been seen in longer than the cookie lifetime + let mut cleaned = 0i64; + let now = chrono::Utc::now(); + + // get all sessions that have ephemeralSession in settings + let sessions: Vec<(String, String, String)> = { + let mut stmt = db + .prepare( + "SELECT s.token, st.settings_json, s.last_seen FROM sessions s + JOIN settings st ON st.session_token = s.token", + ) + .unwrap(); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + for (token, settings_json, last_seen) in sessions { + let settings: serde_json::Value = match serde_json::from_str(&settings_json) { + Ok(v) => v, + Err(_) => continue, + }; + let ephemeral = settings + .get("ephemeralSession") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !ephemeral { + continue; + } + + let cookie_lifetime = settings + .get("cookieLifetime") + .and_then(|v| v.as_i64()) + .unwrap_or(2_592_000); // default 30 days + + if let Ok(seen) = chrono::DateTime::parse_from_rfc3339(&last_seen) { + let elapsed = now.signed_duration_since(seen).num_seconds(); + if elapsed > cookie_lifetime { + delete_account(db, &token); + cleaned += 1; + } + } + } + + if cleaned > 0 { + tracing::info!("cleaned up {} ephemeral sessions", cleaned); + } + cleaned +} + +// federated leaderboard +pub fn insert_federated_leaderboard( + db: &Connection, + origin: &str, + date: &str, + preset: &str, + name: &str, + time: i64, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO federated_leaderboard (origin_domain, puzzle_date, preset, player_name, solve_time, received_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![origin, date, preset, name, time, now], + ).ok(); +} + +pub fn get_federated_leaderboard( + db: &Connection, + date: &str, + preset: &str, + limit: usize, +) -> Vec<(String, String, i64)> { + let mut stmt = db + .prepare( + "SELECT player_name, origin_domain, solve_time FROM federated_leaderboard + WHERE puzzle_date = ?1 AND preset = ?2 ORDER BY solve_time ASC LIMIT ?3", + ) + .unwrap(); + stmt.query_map(params![date, preset, limit as i64], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// insert federated puzzle +pub fn insert_federated_puzzle( + db: &Connection, + id: &str, + title: &str, + w: usize, + h: usize, + grid_json: &str, + clues_json: &str, + puzzle_json: &str, + origin: &str, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT OR IGNORE INTO user_puzzles (id, session_token, title, width, height, grid_json, clues_json, puzzle_json, status, created_at, updated_at, origin_domain) + VALUES (?1, 'federation', ?2, ?3, ?4, ?5, ?6, ?7, 'published', ?8, ?8, ?9)", + params![id, title, w as i64, h as i64, grid_json, clues_json, puzzle_json, now, origin], + ).ok(); +} + +// boosts +pub fn add_ap_boost(db: &Connection, outbox_id: &str, actor_uri: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO ap_boosts (outbox_id, actor_uri, received_at) VALUES (?1, ?2, ?3)", + params![outbox_id, actor_uri, now], + ) + .ok(); +} + +// replies +pub fn add_ap_reply( + db: &Connection, + outbox_id: &str, + puzzle_id: Option<&str>, + actor_uri: &str, + actor_name: Option<&str>, + content: &str, + published: Option<&str>, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO ap_replies (outbox_id, puzzle_id, actor_uri, actor_name, content, published, received_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![outbox_id, puzzle_id, actor_uri, actor_name, content, published, now], + ).ok(); +} + +pub fn get_ap_replies( + db: &Connection, + puzzle_id: &str, + limit: usize, +) -> Vec<(String, Option, String, String)> { + let mut stmt = db.prepare( + "SELECT actor_uri, actor_name, content, COALESCE(published, received_at) FROM ap_replies WHERE puzzle_id = ?1 ORDER BY published DESC LIMIT ?2" + ).unwrap(); + stmt.query_map(params![puzzle_id, limit as i64], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// update peer domain (for Move activities) +pub fn update_peer_domain( + db: &Connection, + old_domain: &str, + new_domain: &str, + new_actor: &str, + new_inbox: &str, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute("DELETE FROM ap_peers WHERE domain = ?1", [old_domain]) + .ok(); + db.execute( + "INSERT OR REPLACE INTO ap_peers (domain, actor_uri, inbox_uri, last_seen, created_at) + VALUES (?1, ?2, ?3, ?4, ?4)", + params![new_domain, new_actor, new_inbox, now], + ) + .ok(); +} + +// wordlist packs + +pub fn create_pack( + db: &Connection, + id: &str, + name: &str, + desc: Option<&str>, + author_session: &str, + author_name: Option<&str>, + tags: Option<&str>, + word_count: i64, + pack_data: &str, +) -> bool { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO wordlist_packs (id, name, description, author_session, author_name, tags, word_count, pack_data, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![id, name, desc, author_session, author_name, tags, word_count, pack_data, now], + ).is_ok() +} + +pub struct PackInfo { + pub id: String, + pub name: String, + pub description: Option, + pub author_name: Option, + pub tags: Option, + pub word_count: i64, + pub download_count: i64, + pub rating_avg: Option, + pub rating_count: i64, + pub pack_data: String, + pub created_at: String, +} + +pub fn get_pack(db: &Connection, id: &str) -> Option { + db.query_row( + "SELECT id, name, description, author_name, tags, word_count, download_count, rating_avg, rating_count, pack_data, created_at + FROM wordlist_packs WHERE id = ?1 AND status = 'published'", + [id], + |row| Ok(PackInfo { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + author_name: row.get(3)?, + tags: row.get(4)?, + word_count: row.get(5)?, + download_count: row.get(6)?, + rating_avg: row.get(7)?, + rating_count: row.get(8)?, + pack_data: row.get(9)?, + created_at: row.get(10)?, + }), + ).ok() +} + +pub fn list_packs( + db: &Connection, + search: Option<&str>, + limit: usize, +) -> Vec<( + String, + String, + Option, + Option, + Option, + i64, + i64, + Option, + i64, + String, +)> { + if let Some(q) = search { + let pattern = format!("%{}%", q); + let mut stmt = db.prepare( + "SELECT id, name, description, author_name, tags, word_count, download_count, rating_avg, rating_count, created_at + FROM wordlist_packs WHERE status = 'published' AND (name LIKE ?1 OR description LIKE ?1 OR tags LIKE ?1) + ORDER BY download_count DESC LIMIT ?2" + ).unwrap(); + stmt.query_map(params![pattern, limit as i64], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } else { + let mut stmt = db.prepare( + "SELECT id, name, description, author_name, tags, word_count, download_count, rating_avg, rating_count, created_at + FROM wordlist_packs WHERE status = 'published' + ORDER BY download_count DESC LIMIT ?1" + ).unwrap(); + stmt.query_map([limit as i64], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + } +} + +pub fn delete_pack(db: &Connection, id: &str, author_session: &str) -> bool { + let rows = db + .execute( + "DELETE FROM wordlist_packs WHERE id = ?1 AND author_session = ?2", + params![id, author_session], + ) + .unwrap_or(0); + rows > 0 +} + +pub fn rate_pack(db: &Connection, pack_id: &str, session_token: &str, rating: i64) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT INTO pack_ratings (pack_id, session_token, rating, created_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(pack_id, session_token) DO UPDATE SET rating = ?3, created_at = ?4", + params![pack_id, session_token, rating, now], + ) + .ok(); + // update aggregate + db.execute( + "UPDATE wordlist_packs SET + rating_avg = (SELECT AVG(CAST(rating AS REAL)) FROM pack_ratings WHERE pack_id = ?1), + rating_count = (SELECT COUNT(*) FROM pack_ratings WHERE pack_id = ?1) + WHERE id = ?1", + [pack_id], + ) + .ok(); +} + +pub fn increment_pack_downloads(db: &Connection, id: &str) { + db.execute( + "UPDATE wordlist_packs SET download_count = download_count + 1 WHERE id = ?1", + [id], + ) + .ok(); +} + +// all-time leaderboard - fastest solve ever per player for a preset +pub fn get_alltime_leaderboard( + db: &Connection, + preset: &str, + limit: usize, +) -> Vec<(String, i64, i64, String)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used, + p.daily_date + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + GROUP BY sr.session_token + ORDER BY best_time ASC + LIMIT ?2" + ).unwrap(); + + stmt.query_map(params![preset, limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?.unwrap_or_default(), + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// weekly leaderboard - best time per player in the last 7 days +pub fn get_weekly_leaderboard( + db: &Connection, + preset: &str, + limit: usize, +) -> Vec<(String, i64, i64)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + AND p.daily_date >= date('now', '-7 days') + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + GROUP BY sr.session_token + ORDER BY best_time ASC + LIMIT ?2" + ).unwrap(); + + stmt.query_map(params![preset, limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// streak leaderboard - top current streaks +pub fn get_streak_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64, i64)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak + FROM streaks st2 + LEFT JOIN sessions s ON st2.session_token = s.token + LEFT JOIN settings st ON st2.session_token = st.session_token + WHERE st2.current_streak > 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + ORDER BY st2.current_streak DESC, st2.best_streak DESC + LIMIT ?1" + ).unwrap(); + + stmt.query_map(params![limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// completionist leaderboard - most total solves +pub fn get_completionist_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE sr.is_complete = 1 AND sr.flagged = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + GROUP BY sr.session_token + ORDER BY total DESC + LIMIT ?1" + ).unwrap(); + + stmt.query_map(params![limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// clean solve leaderboard - zero hints only +pub fn get_clean_leaderboard( + db: &Connection, + date: &str, + preset: &str, + limit: usize, +) -> Vec<(String, i64)> { + let mut stmt = db.prepare( + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE p.daily_date = ?1 AND p.preset = ?2 + AND sr.is_complete = 1 AND sr.flagged = 0 AND sr.hints_used = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + ORDER BY sr.solve_time_secs ASC + LIMIT ?3" + ).unwrap(); + + stmt.query_map(params![date, preset, limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// personal stats - detailed breakdown +pub fn get_personal_stats(db: &Connection, token: &str) -> serde_json::Value { + let total: i64 = db + .query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1", + [token], + |r| r.get(0), + ) + .unwrap_or(0); + + let clean: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND hints_used = 0", + [token], |r| r.get(0), + ).unwrap_or(0); + + let avg_hints: f64 = db.query_row( + "SELECT COALESCE(AVG(hints_used), 0) FROM solve_records WHERE session_token = ?1 AND is_complete = 1", + [token], |r| r.get(0), + ).unwrap_or(0.0); + + // by preset counts + let mut by_preset = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT COALESCE(p.preset, 'custom'), COUNT(*) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 + GROUP BY p.preset", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + by_preset.insert(r.0, serde_json::json!(r.1)); + } + } + + // best times per preset + let mut best_times = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT COALESCE(p.preset, 'custom'), MIN(sr.solve_time_secs) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 + GROUP BY p.preset", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + best_times.insert(r.0, serde_json::json!(r.1)); + } + } + + // avg time per preset + let mut avg_times = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT COALESCE(p.preset, 'custom'), AVG(sr.solve_time_secs) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 + GROUP BY p.preset", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + avg_times.insert(r.0, serde_json::json!(r.1.round() as i64)); + } + } + + let (current_streak, best_streak, _) = get_streak(db, token); + + serde_json::json!({ + "total_solves": total, + "clean_solves": clean, + "avg_hints": (avg_hints * 100.0).round() / 100.0, + "by_preset": by_preset, + "best_times": best_times, + "avg_time_by_preset": avg_times, + "current_streak": current_streak, + "best_streak": best_streak, + }) +} + +// analytics - rich personal stats endpoint +pub fn get_analytics_data(db: &Connection, token: &str) -> serde_json::Value { + // summary stats + let total_solves: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + [token], |r| r.get(0), + ).unwrap_or(0); + + let clean_solves: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 AND hints_used = 0", + [token], |r| r.get(0), + ).unwrap_or(0); + + let total_time: i64 = db.query_row( + "SELECT COALESCE(SUM(solve_time_secs), 0) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + [token], |r| r.get(0), + ).unwrap_or(0); + + let avg_time: i64 = if total_solves > 0 { + total_time / total_solves + } else { + 0 + }; + + let avg_hints: f64 = db.query_row( + "SELECT COALESCE(AVG(hints_used), 0) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + [token], |r| r.get(0), + ).unwrap_or(0.0); + + let (current_streak, best_streak, _) = get_streak(db, token); + + let first_solve: Option = db.query_row( + "SELECT MIN(completed_at) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + [token], |r| r.get(0), + ).ok().flatten().map(|s: String| s.chars().take(10).collect()); + + let puzzles_rated: i64 = db + .query_row( + "SELECT COUNT(*) FROM puzzle_ratings WHERE session_token = ?1", + [token], + |r| r.get(0), + ) + .unwrap_or(0); + + let clues_submitted: i64 = db + .query_row( + "SELECT COUNT(*) FROM community_clues WHERE submitted_by = ?1", + [token], + |r| r.get(0), + ) + .unwrap_or(0); + + // by preset - count, best, avg, clean per preset + let mut by_preset = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT COALESCE(p.preset, 'custom'), + COUNT(*), MIN(sr.solve_time_secs), + CAST(AVG(sr.solve_time_secs) AS INTEGER), + SUM(CASE WHEN sr.hints_used = 0 THEN 1 ELSE 0 END) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + GROUP BY COALESCE(p.preset, 'custom')", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }) + .unwrap(); + for r in rows.flatten() { + by_preset.insert( + r.0, + serde_json::json!({ + "count": r.1, "best": r.2, "avg": r.3, "clean": r.4 + }), + ); + } + } + + // solve history - last 100 + let mut solve_history = Vec::new(); + { + let mut stmt = db.prepare( + "SELECT sr.completed_at, COALESCE(p.preset, 'custom'), sr.solve_time_secs, sr.hints_used + FROM solve_records sr LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + ORDER BY sr.completed_at DESC LIMIT 100" + ).unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .unwrap(); + for r in rows.flatten() { + let date: String = r.0.chars().take(10).collect(); + solve_history.push(serde_json::json!({ + "date": date, "preset": r.1, "time": r.2, "hints": r.3 + })); + } + } + + // activity calendar - solves per day for last 365 days + let mut activity_calendar = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT DATE(completed_at) as d, COUNT(*) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-365 days') + GROUP BY d", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + activity_calendar.insert(r.0, serde_json::json!(r.1)); + } + } + + // by hour - 24 buckets + let mut by_hour = vec![0i64; 24]; + { + let mut stmt = db + .prepare( + "SELECT CAST(strftime('%H', completed_at) AS INTEGER) as h, COUNT(*) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + GROUP BY h", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + let idx = r.0 as usize; + if idx < 24 { + by_hour[idx] = r.1; + } + } + } + + // by day of week - Mon(0) through Sun(6) + // SQLite %w: 0=Sunday, 1=Monday, ..., 6=Saturday + // remap: Sun(0)->6, Mon(1)->0, Tue(2)->1, ..., Sat(6)->5 + let mut by_dow = vec![0i64; 7]; + { + let mut stmt = db + .prepare( + "SELECT CAST(strftime('%w', completed_at) AS INTEGER) as dow, COUNT(*) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + GROUP BY dow", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + let mapped = if r.0 == 0 { 6 } else { (r.0 - 1) as usize }; + if mapped < 7 { + by_dow[mapped] = r.1; + } + } + } + + // time trend - weekly avg for last 12 weeks + let mut time_trend = Vec::new(); + { + let mut stmt = db.prepare( + "SELECT DATE(completed_at, 'weekday 0', '-6 days') as week_start, AVG(solve_time_secs) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-84 days') + GROUP BY week_start ORDER BY week_start" + ).unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + time_trend.push(serde_json::json!({ + "date": r.0, "avg_time": r.1.round() as i64 + })); + } + } + + // hints trend - weekly avg for last 12 weeks + let mut hints_trend = Vec::new(); + { + let mut stmt = db + .prepare( + "SELECT DATE(completed_at, 'weekday 0', '-6 days') as week_start, AVG(hints_used) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-84 days') + GROUP BY week_start ORDER BY week_start", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + hints_trend.push(serde_json::json!({ + "date": r.0, "avg_hints": (r.1 * 100.0).round() / 100.0 + })); + } + } + + // improvement rate: compare avg time last 4 weeks vs previous 4 weeks + let improvement_rate: Option = { + let recent: Option = db + .query_row( + "SELECT AVG(solve_time_secs) FROM solve_records + WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-28 days')", + [token], + |r| r.get(0), + ) + .ok() + .flatten(); + let previous: Option = db.query_row( + "SELECT AVG(solve_time_secs) FROM solve_records + WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-56 days') AND completed_at < DATE('now', '-28 days')", + [token], |r| r.get(0), + ).ok().flatten(); + match (recent, previous) { + (Some(r), Some(p)) if p > 0.0 => Some(((r - p) / p * 100.0 * 10.0).round() / 10.0), + _ => None, + } + }; + + // clean rate trend: weekly clean solve %, 12 weeks + let mut clean_rate_trend = Vec::new(); + { + let mut stmt = db + .prepare( + "SELECT DATE(completed_at, 'weekday 0', '-6 days') as week_start, + SUM(CASE WHEN hints_used = 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-84 days') + GROUP BY week_start ORDER BY week_start", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + clean_rate_trend.push(serde_json::json!({ + "date": r.0, "rate": (r.1 * 10.0).round() / 10.0 + })); + } + } + + // fastest by preset: personal records with dates + let mut fastest_by_preset = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT COALESCE(p.preset, 'custom'), sr.solve_time_secs, DATE(sr.completed_at) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + AND sr.solve_time_secs = ( + SELECT MIN(sr2.solve_time_secs) FROM solve_records sr2 + LEFT JOIN puzzles p2 ON sr2.puzzle_id = p2.id + WHERE sr2.session_token = ?1 AND sr2.is_complete = 1 AND sr2.flagged = 0 + AND COALESCE(p2.preset, 'custom') = COALESCE(p.preset, 'custom') + ) + GROUP BY COALESCE(p.preset, 'custom')", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }) + .unwrap(); + for r in rows.flatten() { + fastest_by_preset.insert( + r.0, + serde_json::json!({ + "time": r.1, "date": r.2 + }), + ); + } + } + + // solve time distribution: 10 buckets from min to max + let solve_time_distribution: Vec = { + let min_max: Option<(i64, i64)> = db + .query_row( + "SELECT MIN(solve_time_secs), MAX(solve_time_secs) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + [token], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .ok(); + match min_max { + Some((min_t, max_t)) if max_t > min_t => { + let bucket_size = ((max_t - min_t) as f64 / 10.0).ceil() as i64; + let mut buckets = vec![0i64; 10]; + let mut stmt = db + .prepare( + "SELECT solve_time_secs FROM solve_records + WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + ) + .unwrap(); + let rows = stmt.query_map([token], |row| row.get::<_, i64>(0)).unwrap(); + for t in rows.flatten() { + let idx = ((t - min_t) / bucket_size).min(9) as usize; + buckets[idx] += 1; + } + buckets + } + _ => vec![0; 10], + } + }; + + // monthly totals: last 12 months + let mut monthly_totals = Vec::new(); + { + let mut stmt = db + .prepare( + "SELECT strftime('%Y-%m', completed_at) as m, COUNT(*) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + AND completed_at >= DATE('now', '-365 days') + GROUP BY m ORDER BY m", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + monthly_totals.push(serde_json::json!({ + "month": r.0, "count": r.1 + })); + } + } + + // completion rate: completed / total started + let completion_rate: f64 = { + let completed: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0", + [token], |r| r.get(0), + ).unwrap_or(0); + let started: i64 = db + .query_row( + "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND flagged = 0", + [token], + |r| r.get(0), + ) + .unwrap_or(0); + if started > 0 { + (completed as f64 / started as f64 * 1000.0).round() / 10.0 + } else { + 0.0 + } + }; + + // avg time by hour: avg solve time for each hour of day + let mut avg_time_by_hour = serde_json::Map::new(); + { + let mut stmt = db + .prepare( + "SELECT CAST(strftime('%H', completed_at) AS INTEGER) as h, + CAST(AVG(solve_time_secs) AS INTEGER) + FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 + GROUP BY h", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + avg_time_by_hour.insert(r.0.to_string(), serde_json::json!(r.1)); + } + } + + // difficulty progression: weekly avg difficulty level + let mut difficulty_progression = Vec::new(); + { + let mut stmt = db + .prepare( + "SELECT DATE(sr.completed_at, 'weekday 0', '-6 days') as week_start, + AVG(CASE COALESCE(p.preset, 'custom') + WHEN 'glyph' THEN 0 WHEN 'rune' THEN 1 WHEN 'scroll' THEN 2 + WHEN 'codex' THEN 3 WHEN 'grimoire' THEN 4 ELSE 2 END) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + AND sr.completed_at >= DATE('now', '-84 days') + GROUP BY week_start ORDER BY week_start", + ) + .unwrap(); + let rows = stmt + .query_map([token], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + }) + .unwrap(); + for r in rows.flatten() { + difficulty_progression.push(serde_json::json!({ + "date": r.0, "avg_difficulty": (r.1 * 100.0).round() / 100.0 + })); + } + } + + // approximate words encountered from puzzle dimensions + let words_encountered: i64 = db + .query_row( + "SELECT COALESCE(SUM(p.width + p.height), 0) + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0", + [token], + |r| r.get(0), + ) + .unwrap_or(0); + + serde_json::json!({ + "summary": { + "total_solves": total_solves, + "clean_solves": clean_solves, + "total_time_secs": total_time, + "avg_time_secs": avg_time, + "avg_hints": (avg_hints * 100.0).round() / 100.0, + "current_streak": current_streak, + "best_streak": best_streak, + "first_solve": first_solve, + "puzzles_rated": puzzles_rated, + "clues_submitted": clues_submitted, + }, + "by_preset": by_preset, + "solve_history": solve_history, + "activity_calendar": activity_calendar, + "by_hour": by_hour, + "by_day_of_week": by_dow, + "time_trend": time_trend, + "hints_trend": hints_trend, + "improvement_rate": improvement_rate, + "clean_rate_trend": clean_rate_trend, + "fastest_by_preset": fastest_by_preset, + "solve_time_distribution": solve_time_distribution, + "monthly_totals": monthly_totals, + "completion_rate": completion_rate, + "words_encountered": words_encountered, + "avg_time_by_hour": avg_time_by_hour, + "difficulty_progression": difficulty_progression, + }) +} + +// percentile - what fraction of solvers were slower +pub fn get_solve_percentile(db: &Connection, puzzle_id: &str, time: i64) -> f64 { + let total: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0", + [puzzle_id], |r| r.get(0), + ).unwrap_or(0); + if total == 0 { + return 0.0; + } + + let slower: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0 AND solve_time_secs > ?2", + params![puzzle_id, time], |r| r.get(0), + ).unwrap_or(0); + + (slower as f64 / total as f64 * 100.0 * 10.0).round() / 10.0 +} + +// total solvers for a puzzle +pub fn get_total_solvers(db: &Connection, puzzle_id: &str) -> i64 { + db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0", + [puzzle_id], |r| r.get(0), + ).unwrap_or(0) +} + +// solver rank for a given time +pub fn get_solve_rank(db: &Connection, puzzle_id: &str, time: i64) -> i64 { + let faster: i64 = db.query_row( + "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0 AND solve_time_secs < ?2", + params![puzzle_id, time], |r| r.get(0), + ).unwrap_or(0); + faster + 1 +} + +// daily leaderboard by specific date with optional clean-only filter +pub fn get_daily_leaderboard_by_date( + db: &Connection, + date: &str, + preset: &str, + clean_only: bool, + limit: usize, +) -> Vec<(String, i64, i64)> { + let sql = if clean_only { + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 + AND sr.hints_used = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + ORDER BY sr.solve_time_secs ASC + LIMIT ?3" + } else { + "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used + FROM solve_records sr + LEFT JOIN sessions s ON sr.session_token = s.token + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + LEFT JOIN settings st ON sr.session_token = st.session_token + WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0 + AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1) + ORDER BY sr.solve_time_secs ASC + LIMIT ?3" + }; + + let mut stmt = db.prepare(sql).unwrap(); + stmt.query_map(params![date, preset, limit as i64], |row| { + Ok(( + row.get::<_, Option>(0)? + .unwrap_or_else(|| "Anonymous".into()), + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +// solve receipts + +pub fn store_receipt( + db: &Connection, + receipt_id: &str, + signature: &str, + expires_at: &str, + puzzle_hash: &str, + solve_time: i64, + hints: i64, + solve_date: &str, + instance_url: &str, + player_name: &str, + room_code: Option<&str>, + mode: Option<&str>, + players: Option<&str>, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT OR REPLACE INTO solve_receipts (receipt_id, puzzle_hash, solve_time_secs, hints_used, solve_date, + instance_url, player_name, room_code, mode, players, created_at, expires_at, signature) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![receipt_id, puzzle_hash, solve_time, hints, solve_date, + instance_url, player_name, room_code, mode, players, now, expires_at, signature], + ).unwrap(); +} + +pub fn get_receipt(db: &Connection, receipt_id: &str) -> Option { + let now = chrono::Utc::now().to_rfc3339(); + db.query_row( + "SELECT receipt_id, puzzle_hash, solve_time_secs, hints_used, solve_date, + instance_url, player_name, room_code, mode, players, created_at, expires_at, signature + FROM solve_receipts WHERE receipt_id = ?1 AND expires_at > ?2", + params![receipt_id, now], + |row| { + let players_str: Option = row.get(9)?; + let players: Vec = players_str + .map(|s| serde_json::from_str(&s).unwrap_or_default()) + .unwrap_or_default(); + let receipt = serde_json::json!({ + "receipt": { + "receipt_id": row.get::<_, String>(0)?, + "puzzle_hash": row.get::<_, String>(1)?, + "solve_time_secs": row.get::<_, i64>(2)?, + "hints_used": row.get::<_, i64>(3)?, + "solve_date": row.get::<_, String>(4)?, + "instance_url": row.get::<_, String>(5)?, + "player_name": row.get::<_, String>(6)?, + "room_code": row.get::<_, Option>(7)?, + "mode": row.get::<_, Option>(8)?, + "players": players, + "created_at": row.get::<_, String>(10)?, + "expires_at": row.get::<_, String>(11)?, + }, + "signature": row.get::<_, String>(12)?, + }); + Ok(serde_json::to_string(&receipt).unwrap()) + }, + ).ok() +} + +pub fn is_receipt_claimed(db: &Connection, receipt_id: &str) -> bool { + db.query_row( + "SELECT 1 FROM claimed_receipts WHERE receipt_id = ?1", + [receipt_id], + |_| Ok(()), + ) + .is_ok() +} + +pub fn claim_receipt(db: &Connection, receipt_id: &str, session_token: &str, origin: &str) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT OR IGNORE INTO claimed_receipts (receipt_id, session_token, origin_instance, claimed_at) + VALUES (?1, ?2, ?3, ?4)", + params![receipt_id, session_token, origin, now], + ).unwrap(); +} + +pub fn cleanup_expired_receipts(db: &Connection) -> i64 { + let now = chrono::Utc::now().to_rfc3339(); + let count = db + .execute("DELETE FROM solve_receipts WHERE expires_at < ?1", [&now]) + .unwrap_or(0) as i64; + if count > 0 { + tracing::info!("cleaned up {} expired receipts", count); + } + count +} + +pub fn get_solve_history(db: &Connection, token: &str, limit: usize) -> Vec { + let mut items = Vec::new(); + let mut stmt = db.prepare( + "SELECT sr.completed_at, COALESCE(p.preset, 'custom'), sr.solve_time_secs, sr.hints_used, p.short_id + FROM solve_records sr + LEFT JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0 + ORDER BY sr.completed_at DESC + LIMIT ?2" + ).unwrap(); + let rows = stmt + .query_map(params![token, limit as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Option>(4)?, + )) + }) + .unwrap(); + for r in rows.flatten() { + let date: String = r.0.chars().take(10).collect(); + items.push(serde_json::json!({ + "date": date, "preset": r.1, "time": r.2, "hints": r.3, "short_id": r.4 + })); + } + items +} + +// constructor analytics for a published puzzle +pub fn get_puzzle_analytics( + db: &Connection, + puzzle_id: &str, + token: &str, +) -> Option { + // verify ownership + let owns: bool = db.query_row( + "SELECT COUNT(*) > 0 FROM user_puzzles WHERE id = ?1 AND session_token = ?2 AND status = 'published'", + params![puzzle_id, token], |r| r.get(0), + ).unwrap_or(false); + if !owns { + return None; + } + + // get the puzzle_json to find the actual puzzle_id in the puzzles table + // user_puzzles.puzzle_json contains the generated puzzle data + let actual_puzzle_id: Option = db + .query_row( + "SELECT puzzle_json FROM user_puzzles WHERE id = ?1", + [puzzle_id], + |r| r.get::<_, Option>(0), + ) + .ok() + .flatten() + .and_then(|json| serde_json::from_str::(&json).ok()) + .and_then(|v| v.get("id")?.as_str().map(|s| s.to_string())); + + let qid = actual_puzzle_id.as_deref().unwrap_or(puzzle_id); + + let total_solvers: i64 = db + .query_row( + "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1", + [qid], + |r| r.get(0), + ) + .unwrap_or(0); + + let total_attempts: i64 = db + .query_row( + "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1", + [qid], + |r| r.get(0), + ) + .unwrap_or(0); + + let avg_time: f64 = db.query_row( + "SELECT COALESCE(AVG(solve_time_secs), 0) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1", + [qid], |r| r.get(0), + ).unwrap_or(0.0); + + let completion_rate = if total_attempts > 0 { + total_solvers as f64 / total_attempts as f64 + } else { + 0.0 + }; + + // hint heatmap from puzzle_hints - not per-cell but per-user hint count + // we don't have per-cell hint tracking, so return total hints per user as a summary + let total_hints: i64 = db + .query_row( + "SELECT COALESCE(SUM(hint_count), 0) FROM puzzle_hints WHERE puzzle_id = ?1", + [qid], + |r| r.get(0), + ) + .unwrap_or(0); + + // get puzzle dimensions + let (width, height): (i64, i64) = db + .query_row( + "SELECT width, height FROM user_puzzles WHERE id = ?1", + [puzzle_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap_or((0, 0)); + + Some(serde_json::json!({ + "total_solvers": total_solvers, + "total_attempts": total_attempts, + "avg_time": avg_time.round() as i64, + "completion_rate": completion_rate, + "total_hints": total_hints, + "width": width, + "height": height, + })) +} + +// daily puzzle archive +pub fn get_daily_archive( + db: &Connection, + preset: Option<&str>, + token: &str, + page: usize, + per_page: usize, +) -> Vec { + let offset = page * per_page; + let mut items = Vec::new(); + + let (sql, use_preset) = if let Some(p) = preset { + ( + "SELECT p.daily_date, p.preset, p.short_id, p.width, p.height, + CASE WHEN sr.is_complete = 1 THEN 1 ELSE 0 END as solved + FROM puzzles p + LEFT JOIN solve_records sr ON sr.puzzle_id = p.id AND sr.session_token = ?1 + WHERE p.daily_date IS NOT NULL AND p.daily_date <= date('now') AND p.preset = ?2 + ORDER BY p.daily_date DESC, p.preset ASC + LIMIT ?3 OFFSET ?4" + .to_string(), + Some(p.to_string()), + ) + } else { + ( + "SELECT p.daily_date, p.preset, p.short_id, p.width, p.height, + CASE WHEN sr.is_complete = 1 THEN 1 ELSE 0 END as solved + FROM puzzles p + LEFT JOIN solve_records sr ON sr.puzzle_id = p.id AND sr.session_token = ?1 + WHERE p.daily_date IS NOT NULL AND p.daily_date <= date('now') + ORDER BY p.daily_date DESC, p.preset ASC + LIMIT ?2 OFFSET ?3" + .to_string(), + None, + ) + }; + + if let Some(ref p) = use_preset { + let mut stmt = db.prepare(&sql).unwrap(); + let rows = stmt + .query_map(params![token, p, per_page as i64, offset as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + }) + .unwrap(); + for r in rows.flatten() { + items.push(serde_json::json!({ + "date": r.0, "preset": r.1, "short_id": r.2, + "width": r.3, "height": r.4, "solved": r.5 != 0, + })); + } + } else { + let mut stmt = db.prepare(&sql).unwrap(); + let rows = stmt + .query_map(params![token, per_page as i64, offset as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + }) + .unwrap(); + for r in rows.flatten() { + items.push(serde_json::json!({ + "date": r.0, "preset": r.1, "short_id": r.2, + "width": r.3, "height": r.4, "solved": r.5 != 0, + })); + } + } + items +} + +// daily challenges + +const CHALLENGE_TYPES: &[&str] = &[ + "purist", + "speed_demon", + "surgeon", + "no_regrets", + "iron_will", + "trust_fall", + "inkwell", + "night_owl", + "early_bird", + "rush_hour", + "night_shift", + "afternoon_tea", + "contrarian", + "sunday_stroll", + "blitz", + "opera_singer", + "tongue_twister", + "mic_drop", + "jackrabbit", + "metronome", + "quickdraw", + "bookends", + "long_haul", + "fast_fingers", + "three_in_a_row", + "alphabetical", + "lucky_seven", + "even_steven", + "minimalist", + "overtime", +]; + +pub fn get_daily_challenge(db: &Connection, date: &str, preset: &str) -> String { + // check if already assigned + if let Ok(ct) = db.query_row( + "SELECT challenge_type FROM daily_challenges WHERE date = ?1 AND preset = ?2", + params![date, preset], + |row| row.get::<_, String>(0), + ) { + return ct; + } + + // generate all 5 for this date at once + generate_day_challenges(db, date); + + // now fetch the one we need + db.query_row( + "SELECT challenge_type FROM daily_challenges WHERE date = ?1 AND preset = ?2", + params![date, preset], + |row| row.get::<_, String>(0), + ) + .unwrap_or_else(|_| CHALLENGE_TYPES[0].to_string()) +} + +fn generate_day_challenges(db: &Connection, date: &str) { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let presets = ["glyph", "rune", "scroll", "codex", "grimoire"]; + let cooldown_days = 14; + + // check if any already exist for this date (another thread might have beaten us) + let existing: i64 = db + .query_row( + "SELECT COUNT(*) FROM daily_challenges WHERE date = ?1", + [date], + |r| r.get(0), + ) + .unwrap_or(0); + if existing >= 5 { + return; + } + + // get challenge history for the last 14 days + let mut penalties: std::collections::HashMap = std::collections::HashMap::new(); + if let Ok(mut stmt) = db.prepare( + "SELECT challenge_type, date FROM daily_challenges WHERE date > date(?1, '-14 days') AND date < ?1" + ) { + if let Ok(rows) = stmt.query_map([date], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) { + for r in rows.flatten() { + let (ct, d) = r; + // calculate days ago + let days_ago = if let (Ok(target), Ok(entry)) = ( + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d"), + chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d"), + ) { + (target - entry).num_days() + } else { + cooldown_days as i64 + }; + let penalty = (cooldown_days as i64).saturating_sub(days_ago).max(0); + let entry = penalties.entry(ct).or_insert(0); + *entry = (*entry).max(penalty); // use the highest penalty (most recent usage) + } + } + } + + // score all challenges and sort by penalty (lowest first), break ties with hash + let mut scored: Vec<(i64, u64, &str)> = CHALLENGE_TYPES + .iter() + .map(|ct| { + let penalty = penalties.get(*ct).copied().unwrap_or(0); + let mut hasher = DefaultHasher::new(); + date.hash(&mut hasher); + ct.hash(&mut hasher); + let tiebreak = hasher.finish(); + (penalty, tiebreak, *ct) + }) + .collect(); + + scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + + // pick 5 unique challenges (lowest penalty first) + for (i, preset) in presets.iter().enumerate() { + if i < scored.len() { + db.execute( + "INSERT OR IGNORE INTO daily_challenges (date, preset, challenge_type) VALUES (?1, ?2, ?3)", + params![date, preset, scored[i].2], + ).ok(); + } + } +} + +pub fn complete_challenge( + db: &Connection, + token: &str, + date: &str, + preset: &str, + challenge_type: &str, + verified: bool, + client_attested: bool, +) { + let now = chrono::Utc::now().to_rfc3339(); + db.execute( + "INSERT OR IGNORE INTO challenge_completions (session_token, date, preset, challenge_type, verified, client_attested, completed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![token, date, preset, challenge_type, verified as i64, client_attested as i64, now], + ).ok(); +} + +/// (solve_time_secs, hints_used, completed_at) for the newest completed solve +/// of the daily puzzle matching date+preset. +pub fn get_challenge_solve_info( + db: &Connection, + token: &str, + date: &str, + preset: &str, +) -> Option<(i64, i64, String)> { + db.query_row( + "SELECT sr.solve_time_secs, sr.hints_used, sr.completed_at + FROM solve_records sr + JOIN puzzles p ON sr.puzzle_id = p.id + WHERE sr.session_token = ?1 AND p.daily_date = ?2 AND p.preset = ?3 + AND sr.is_complete = 1 + ORDER BY sr.completed_at DESC LIMIT 1", + params![token, date, preset], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .ok() +} + +pub fn has_completed_challenge(db: &Connection, token: &str, date: &str, preset: &str) -> bool { + db.query_row( + "SELECT COUNT(*) FROM challenge_completions WHERE session_token = ?1 AND date = ?2 AND preset = ?3", + params![token, date, preset], + |row| row.get::<_, i64>(0), + ).unwrap_or(0) > 0 +} + +pub fn get_today_challenge_completions( + db: &Connection, + token: &str, + date: &str, +) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let mut stmt = db + .prepare("SELECT preset FROM challenge_completions WHERE session_token = ?1 AND date = ?2") + .unwrap(); + let rows = stmt + .query_map(params![token, date], |row| row.get::<_, String>(0)) + .unwrap(); + for r in rows { + if let Ok(preset) = r { + map.insert(preset, true); + } + } + map +}