simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

database.rs (41097B)


      1 use std::collections::HashMap;
      2 use rusqlite::{params, Connection};
      3 
      4 use crate::error::AppError;
      5 
      6 // Target type constants for votes
      7 pub const TARGET_TYPE_STORY: i64 = 1;
      8 pub const TARGET_TYPE_COMMENT: i64 = 2;
      9 
     10 // ==========================================================================
     11 // Data types
     12 // ==========================================================================
     13 
     14 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     15 pub struct Story {
     16     pub id: i64,
     17     pub url: Option<String>,
     18     pub title: String,
     19     pub text: String,
     20     pub domain: Option<String>,
     21     pub score: i64,
     22     pub comment_count: i64,
     23     pub published: i64,
     24     pub created_by: Option<String>,
     25     pub author: Option<String>,
     26     pub language: String,
     27 }
     28 
     29 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     30 pub struct Comment {
     31     pub id: i64,
     32     pub story_id: i64,
     33     pub parent_id: Option<i64>,
     34     pub path: String,
     35     pub depth: i64,
     36     pub text: String,
     37     pub score: i64,
     38     pub created_at: i64,
     39     pub created_by: String,
     40 }
     41 
     42 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     43 pub struct Category {
     44     pub id: i64,
     45     pub name: Option<String>,
     46 }
     47 
     48 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     49 pub struct User {
     50     pub id: i64,
     51     pub email: String,
     52     pub password_hash: String,
     53     pub username: String,
     54     pub about: Option<String>,
     55     pub karma: i64,
     56     pub created_at: i64,
     57     pub email_verified: bool,
     58     pub verification_token: Option<String>,
     59 }
     60 
     61 /// Kept for template compatibility - maps to User fields
     62 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     63 pub struct UserProfile {
     64     pub id: i64,
     65     pub user_id: String,
     66     pub username: String,
     67     pub about: Option<String>,
     68     pub karma: i64,
     69     pub created_at: String,
     70 }
     71 
     72 impl From<User> for UserProfile {
     73     fn from(u: User) -> Self {
     74         Self {
     75             id: u.id,
     76             user_id: u.id.to_string(),
     77             username: u.username,
     78             about: u.about,
     79             karma: u.karma,
     80             created_at: time_ago(u.created_at),
     81         }
     82     }
     83 }
     84 
     85 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     86 pub struct SearchResult {
     87     pub id: i64,
     88     pub title: String,
     89     pub text: String,
     90     pub score: i64,
     91     pub comment_count: i64,
     92 }
     93 
     94 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
     95 pub struct StoryMeta {
     96     pub story_id: i64,
     97     pub author_username: Option<String>,
     98     pub tags: Vec<String>,
     99 }
    100 
    101 // ==========================================================================
    102 // View types (for templates)
    103 // ==========================================================================
    104 
    105 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
    106 pub struct StoryWithMeta {
    107     pub id: i64,
    108     pub url: Option<String>,
    109     pub title: String,
    110     pub text: String,
    111     pub domain: Option<String>,
    112     pub score: i64,
    113     pub comment_count: i64,
    114     pub published: i64,
    115     pub created_by: Option<String>,
    116     pub tags: Vec<Category>,
    117     pub author_username: Option<String>,
    118     pub time_ago: String,
    119     pub user_voted: bool,
    120     pub rank: usize,
    121 }
    122 
    123 impl StoryWithMeta {
    124     pub fn from_story(
    125         story: Story,
    126         tags: Vec<Category>,
    127         author_username: Option<String>,
    128         time_ago: String,
    129         user_voted: bool,
    130         rank: usize,
    131     ) -> Self {
    132         Self {
    133             id: story.id,
    134             url: story.url,
    135             title: story.title,
    136             text: story.text,
    137             domain: story.domain,
    138             score: story.score,
    139             comment_count: story.comment_count,
    140             published: story.published,
    141             created_by: story.created_by,
    142             tags,
    143             author_username,
    144             time_ago,
    145             user_voted,
    146             rank,
    147         }
    148     }
    149 }
    150 
    151 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
    152 pub struct CommentWithMeta {
    153     pub id: i64,
    154     pub story_id: i64,
    155     pub parent_id: Option<i64>,
    156     pub path: String,
    157     pub depth: i64,
    158     pub indent_px: i64,
    159     pub text: String,
    160     pub score: i64,
    161     pub created_at: i64,
    162     pub created_by: String,
    163     pub author_username: Option<String>,
    164     pub time_ago: String,
    165     pub user_voted: bool,
    166 }
    167 
    168 impl CommentWithMeta {
    169     pub fn from_comment(
    170         comment: Comment,
    171         author_username: Option<String>,
    172         time_ago: String,
    173         user_voted: bool,
    174     ) -> Self {
    175         Self {
    176             id: comment.id,
    177             story_id: comment.story_id,
    178             parent_id: comment.parent_id,
    179             path: comment.path.clone(),
    180             depth: comment.depth,
    181             indent_px: comment.depth * 20,
    182             text: comment.text,
    183             score: comment.score,
    184             created_at: comment.created_at,
    185             created_by: comment.created_by,
    186             author_username,
    187             time_ago,
    188             user_voted,
    189         }
    190     }
    191 }
    192 
    193 // ==========================================================================
    194 // Database
    195 // ==========================================================================
    196 
    197 const PRAGMAS: &str = "\
    198     PRAGMA journal_mode=WAL;\
    199     PRAGMA synchronous=NORMAL;\
    200     PRAGMA foreign_keys=ON;\
    201     PRAGMA busy_timeout=5000;\
    202     PRAGMA cache_size=-64000;\
    203     PRAGMA mmap_size=268435456;\
    204     PRAGMA temp_store=MEMORY;\
    205 ";
    206 
    207 type ReadJob = Box<dyn FnOnce(&Connection) + Send>;
    208 type WriteJob = Box<dyn FnOnce(&Connection) + Send>;
    209 
    210 fn open_conn(path: &str) -> Result<Connection, AppError> {
    211     let c = Connection::open(path)
    212         .map_err(|e| AppError::Database(format!("Failed to open db: {e}")))?;
    213     c.execute_batch(PRAGMAS)
    214         .map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {e}")))?;
    215     Ok(c)
    216 }
    217 
    218 pub struct Database {
    219     read_tx: crossbeam_channel::Sender<ReadJob>,
    220     write_tx: std::sync::mpsc::Sender<WriteJob>,
    221 }
    222 
    223 impl Database {
    224     pub fn new(path: &str) -> Result<Self, AppError> {
    225         if let Some(parent) = std::path::Path::new(path).parent() {
    226             std::fs::create_dir_all(parent)
    227                 .map_err(|e| AppError::Database(format!("Failed to create db directory: {e}")))?;
    228         }
    229 
    230         // Run migrations
    231         let migration_conn = open_conn(path)?;
    232         crate::migrations::run(&migration_conn).map_err(AppError::Database)?;
    233         drop(migration_conn);
    234 
    235         // Dedicated read pool: N threads, each owns a connection
    236         let cores = std::thread::available_parallelism()
    237             .map(|n| n.get())
    238             .unwrap_or(4);
    239         let (read_tx, read_rx) = crossbeam_channel::unbounded::<ReadJob>();
    240         for _ in 0..cores {
    241             let rx = read_rx.clone();
    242             let p = path.to_string();
    243             std::thread::spawn(move || {
    244                 let conn = open_conn(&p).expect("failed to open read connection");
    245                 while let Ok(job) = rx.recv() {
    246                     job(&conn);
    247                 }
    248             });
    249         }
    250 
    251         // Dedicated writer: 1 thread, owns the write connection
    252         let (write_tx, write_rx) = std::sync::mpsc::channel::<WriteJob>();
    253         let p = path.to_string();
    254         std::thread::spawn(move || {
    255             let conn = open_conn(&p).expect("failed to open write connection");
    256             while let Ok(job) = write_rx.recv() {
    257                 job(&conn);
    258             }
    259         });
    260 
    261         Ok(Self { read_tx, write_tx })
    262     }
    263 
    264     /// Dispatch a read query to the read pool, await the result.
    265     pub async fn with_read<T: Send + 'static>(
    266         &self,
    267         f: impl FnOnce(&Connection) -> Result<T, AppError> + Send + 'static,
    268     ) -> Result<T, AppError> {
    269         let (tx, rx) = tokio::sync::oneshot::channel();
    270         self.read_tx.send(Box::new(move |conn| {
    271             let _ = tx.send(f(conn));
    272         })).map_err(|_| AppError::Database("read pool gone".into()))?;
    273         rx.await.map_err(|_| AppError::Database("read cancelled".into()))?
    274     }
    275 
    276     /// Dispatch a write to the single writer thread, await the result.
    277     pub async fn with_write<T: Send + 'static>(
    278         &self,
    279         f: impl FnOnce(&Connection) -> Result<T, AppError> + Send + 'static,
    280     ) -> Result<T, AppError> {
    281         let (tx, rx) = tokio::sync::oneshot::channel();
    282         self.write_tx.send(Box::new(move |conn| {
    283             let _ = tx.send(f(conn));
    284         })).map_err(|_| AppError::Database("writer gone".into()))?;
    285         rx.await.map_err(|_| AppError::Database("write cancelled".into()))?
    286     }
    287 
    288     // ======================================================================
    289     // Story queries
    290     // ======================================================================
    291 
    292     fn row_to_story(row: &rusqlite::Row) -> rusqlite::Result<Story> {
    293         let created_by: Option<i64> = row.get("created_by")?;
    294         Ok(Story {
    295             id: row.get("id")?,
    296             url: row.get("url")?,
    297             title: row.get("title")?,
    298             text: row.get("text")?,
    299             domain: row.get("domain")?,
    300             score: row.get("score")?,
    301             comment_count: row.get("comment_count")?,
    302             published: row.get("published")?,
    303             created_by: created_by.map(|id| id.to_string()),
    304             author: row.get("author")?,
    305             language: row.get("language")?,
    306         })
    307     }
    308 
    309     pub async fn get_stories_new(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> {
    310         self.with_read(move |conn| {
    311             let mut stmt = conn.prepare_cached(
    312                 "SELECT * FROM story ORDER BY published DESC LIMIT ?1 OFFSET ?2"
    313             ).map_err(|e| AppError::Database(e.to_string()))?;
    314 
    315             let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story)
    316                 .map_err(|e| AppError::Database(e.to_string()))?
    317                 .collect::<Result<Vec<_>, _>>()
    318                 .map_err(|e| AppError::Database(e.to_string()))?;
    319 
    320             Ok(stories)
    321         }).await
    322     }
    323 
    324     pub async fn get_stories_top(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> {
    325         self.with_read(move |conn| {
    326             let mut stmt = conn.prepare_cached(
    327                 "SELECT * FROM story ORDER BY score DESC, published DESC LIMIT ?1 OFFSET ?2"
    328             ).map_err(|e| AppError::Database(e.to_string()))?;
    329 
    330             let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story)
    331                 .map_err(|e| AppError::Database(e.to_string()))?
    332                 .collect::<Result<Vec<_>, _>>()
    333                 .map_err(|e| AppError::Database(e.to_string()))?;
    334 
    335             Ok(stories)
    336         }).await
    337     }
    338 
    339     pub async fn get_stories_hot(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> {
    340         let fetch_limit = (limit + offset) * 2;
    341         self.with_read(move |conn| {
    342             let mut stmt = conn.prepare_cached(
    343                 "SELECT * FROM story ORDER BY published DESC LIMIT ?1"
    344             ).map_err(|e| AppError::Database(e.to_string()))?;
    345 
    346             let mut stories: Vec<Story> = stmt.query_map(params![fetch_limit as i64], Self::row_to_story)
    347                 .map_err(|e| AppError::Database(e.to_string()))?
    348                 .collect::<Result<Vec<_>, _>>()
    349                 .map_err(|e| AppError::Database(e.to_string()))?;
    350 
    351             stories.sort_by(|a, b| {
    352                 let score_a = hot_score(a.score, a.published);
    353                 let score_b = hot_score(b.score, b.published);
    354                 score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal)
    355             });
    356 
    357             let result: Vec<Story> = stories.into_iter().skip(offset).take(limit).collect();
    358             Ok(result)
    359         }).await
    360     }
    361 
    362     pub async fn get_story(&self, id: i64) -> Result<Option<Story>, AppError> {
    363         self.with_read(move |conn| {
    364             let mut stmt = conn.prepare_cached("SELECT * FROM story WHERE id = ?1")
    365                 .map_err(|e| AppError::Database(e.to_string()))?;
    366 
    367             let mut rows = stmt.query_map(params![id], Self::row_to_story)
    368                 .map_err(|e| AppError::Database(e.to_string()))?;
    369 
    370             match rows.next() {
    371                 Some(Ok(story)) => Ok(Some(story)),
    372                 Some(Err(e)) => Err(AppError::Database(e.to_string())),
    373                 None => Ok(None),
    374             }
    375         }).await
    376     }
    377 
    378     pub async fn create_story(
    379         &self,
    380         user_id: i64,
    381         title: &str,
    382         url: Option<&str>,
    383         text: &str,
    384         domain: Option<&str>,
    385         language: &str,
    386     ) -> Result<Story, AppError> {
    387         let title = title.to_string();
    388         let url = url.map(|s| s.to_string());
    389         let text = text.to_string();
    390         let domain = domain.map(|s| s.to_string());
    391         let language = language.to_string();
    392         self.with_write(move |conn| {
    393             let published = now_epoch();
    394 
    395             conn.query_row(
    396                 "INSERT INTO story (title, url, text, domain, published, language, created_by)
    397                  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
    398                  RETURNING *",
    399                 params![title, url, text, domain, published, language, user_id],
    400                 Self::row_to_story,
    401             ).map_err(|e| AppError::Database(e.to_string()))
    402         }).await
    403     }
    404 
    405     pub async fn update_story(
    406         &self,
    407         id: i64,
    408         title: &str,
    409         url: Option<&str>,
    410         text: &str,
    411         domain: Option<&str>,
    412     ) -> Result<(), AppError> {
    413         let title = title.to_string();
    414         let url = url.map(|s| s.to_string());
    415         let text = text.to_string();
    416         let domain = domain.map(|s| s.to_string());
    417         self.with_write(move |conn| {
    418             conn.execute(
    419                 "UPDATE story SET title = ?1, url = ?2, text = ?3, domain = ?4 WHERE id = ?5",
    420                 params![title, url, text, domain, id],
    421             ).map_err(|e| AppError::Database(e.to_string()))?;
    422             Ok(())
    423         }).await
    424     }
    425 
    426     pub async fn get_stories_by_tag(&self, category_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> {
    427         self.with_read(move |conn| {
    428             let mut stmt = conn.prepare_cached(
    429                 "SELECT s.* FROM story s
    430                  JOIN story_category sc ON sc.news_item_id = s.id
    431                  WHERE sc.category_id = ?1
    432                  ORDER BY s.published DESC
    433                  LIMIT ?2 OFFSET ?3"
    434             ).map_err(|e| AppError::Database(e.to_string()))?;
    435 
    436             let stories = stmt.query_map(params![category_id, limit as i64, offset as i64], Self::row_to_story)
    437                 .map_err(|e| AppError::Database(e.to_string()))?
    438                 .collect::<Result<Vec<_>, _>>()
    439                 .map_err(|e| AppError::Database(e.to_string()))?;
    440 
    441             Ok(stories)
    442         }).await
    443     }
    444 
    445     pub async fn get_stories_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> {
    446         self.with_read(move |conn| {
    447             let mut stmt = conn.prepare_cached(
    448                 "SELECT * FROM story WHERE created_by = ?1 ORDER BY published DESC LIMIT ?2 OFFSET ?3"
    449             ).map_err(|e| AppError::Database(e.to_string()))?;
    450 
    451             let stories = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_story)
    452                 .map_err(|e| AppError::Database(e.to_string()))?
    453                 .collect::<Result<Vec<_>, _>>()
    454                 .map_err(|e| AppError::Database(e.to_string()))?;
    455 
    456             Ok(stories)
    457         }).await
    458     }
    459 
    460     /// Search stories with selectivity check. Terms appearing in more than
    461     /// `max_doc_ratio` of all documents are rejected — they produce low-quality
    462     /// results and are expensive to rank.
    463     pub async fn search_stories(
    464         &self,
    465         query: &str,
    466         limit: usize,
    467         max_doc_ratio: f64,
    468     ) -> Result<Vec<SearchResult>, AppError> {
    469         let query = query.to_string();
    470         self.with_read(move |conn| {
    471             // Check selectivity: reject terms that match too many documents
    472             let total_docs: i64 = conn
    473                 .query_row("SELECT COUNT(*) FROM story", [], |row| row.get(0))
    474                 .map_err(|e| AppError::Database(e.to_string()))?;
    475 
    476             if total_docs > 0 {
    477                 // Check how many documents match. This uses the FTS index
    478                 // (fast) without ranking (the expensive part).
    479                 let match_count: i64 = conn
    480                     .query_row(
    481                         "SELECT COUNT(*) FROM story_fts WHERE story_fts MATCH ?1",
    482                         params![query],
    483                         |row| row.get(0),
    484                     )
    485                     .unwrap_or(0);
    486 
    487                 if match_count as f64 / total_docs as f64 > max_doc_ratio {
    488                     return Ok(vec![]);
    489                 }
    490             }
    491 
    492             let mut stmt = conn.prepare_cached(
    493                 "SELECT s.id, s.title, s.text, s.score, s.comment_count
    494                  FROM story_fts fts
    495                  JOIN story s ON s.id = fts.rowid
    496                  WHERE story_fts MATCH ?1
    497                  ORDER BY rank
    498                  LIMIT ?2"
    499             ).map_err(|e| AppError::Database(e.to_string()))?;
    500 
    501             let results = stmt.query_map(params![query, limit as i64], |row| {
    502                 Ok(SearchResult {
    503                     id: row.get(0)?,
    504                     title: row.get(1)?,
    505                     text: row.get(2)?,
    506                     score: row.get(3)?,
    507                     comment_count: row.get(4)?,
    508                 })
    509             })
    510             .map_err(|e| AppError::Database(e.to_string()))?
    511             .collect::<Result<Vec<_>, _>>()
    512             .map_err(|e| AppError::Database(e.to_string()))?;
    513 
    514             Ok(results)
    515         }).await
    516     }
    517 
    518     // ======================================================================
    519     // Comment queries
    520     // ======================================================================
    521 
    522     fn row_to_comment(row: &rusqlite::Row) -> rusqlite::Result<Comment> {
    523         let created_by: i64 = row.get("created_by")?;
    524         Ok(Comment {
    525             id: row.get("id")?,
    526             story_id: row.get("story_id")?,
    527             parent_id: row.get("parent_id")?,
    528             path: row.get("path")?,
    529             depth: row.get("depth")?,
    530             text: row.get("text")?,
    531             score: row.get("score")?,
    532             created_at: row.get("created_at")?,
    533             created_by: created_by.to_string(),
    534         })
    535     }
    536 
    537     pub async fn get_comments_for_story(&self, story_id: i64) -> Result<Vec<Comment>, AppError> {
    538         self.with_read(move |conn| {
    539             let mut stmt = conn.prepare_cached(
    540                 "SELECT * FROM comment WHERE story_id = ?1 ORDER BY path"
    541             ).map_err(|e| AppError::Database(e.to_string()))?;
    542 
    543             let comments = stmt.query_map(params![story_id], Self::row_to_comment)
    544                 .map_err(|e| AppError::Database(e.to_string()))?
    545                 .collect::<Result<Vec<_>, _>>()
    546                 .map_err(|e| AppError::Database(e.to_string()))?;
    547 
    548             Ok(comments)
    549         }).await
    550     }
    551 
    552     pub async fn create_comment(
    553         &self,
    554         user_id: i64,
    555         story_id: i64,
    556         parent_id: Option<i64>,
    557         text: &str,
    558     ) -> Result<Comment, AppError> {
    559         let text = text.to_string();
    560         self.with_write(move |conn| {
    561             // Calculate path and depth
    562             let (path, depth) = match parent_id {
    563                 Some(pid) => {
    564                     let parent: Option<(String, i64)> = conn.query_row(
    565                         "SELECT path, depth FROM comment WHERE id = ?1",
    566                         params![pid],
    567                         |row| Ok((row.get(0)?, row.get(1)?)),
    568                     ).ok();
    569 
    570                     match parent {
    571                         Some((parent_path, parent_depth)) => {
    572                             let sibling_count: i64 = conn.query_row(
    573                                 "SELECT COUNT(*) FROM comment WHERE parent_id = ?1",
    574                                 params![pid],
    575                                 |row| row.get(0),
    576                             ).map_err(|e| AppError::Database(e.to_string()))?;
    577 
    578                             let path = format!("{}.{}", parent_path, sibling_count + 1);
    579                             (path, parent_depth + 1)
    580                         }
    581                         None => {
    582                             // Parent not found, treat as root
    583                             let root_count: i64 = conn.query_row(
    584                                 "SELECT COUNT(*) FROM comment WHERE story_id = ?1 AND depth = 0",
    585                                 params![story_id],
    586                                 |row| row.get(0),
    587                             ).map_err(|e| AppError::Database(e.to_string()))?;
    588                             ((root_count + 1).to_string(), 0)
    589                         }
    590                     }
    591                 }
    592                 None => {
    593                     let root_count: i64 = conn.query_row(
    594                         "SELECT COUNT(*) FROM comment WHERE story_id = ?1 AND depth = 0",
    595                         params![story_id],
    596                         |row| row.get(0),
    597                     ).map_err(|e| AppError::Database(e.to_string()))?;
    598                     ((root_count + 1).to_string(), 0)
    599                 }
    600             };
    601 
    602             conn.query_row(
    603                 "INSERT INTO comment (story_id, parent_id, path, depth, text, created_by, created_at)
    604                  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
    605                  RETURNING *",
    606                 params![story_id, parent_id, path, depth, text, user_id, now_epoch()],
    607                 Self::row_to_comment,
    608             ).map_err(|e| AppError::Database(e.to_string()))
    609         }).await
    610     }
    611 
    612     pub async fn get_comment(&self, id: i64) -> Result<Option<Comment>, AppError> {
    613         self.with_read(move |conn| {
    614             let mut stmt = conn.prepare_cached("SELECT * FROM comment WHERE id = ?1")
    615                 .map_err(|e| AppError::Database(e.to_string()))?;
    616 
    617             let mut rows = stmt.query_map(params![id], Self::row_to_comment)
    618                 .map_err(|e| AppError::Database(e.to_string()))?;
    619 
    620             match rows.next() {
    621                 Some(Ok(comment)) => Ok(Some(comment)),
    622                 Some(Err(e)) => Err(AppError::Database(e.to_string())),
    623                 None => Ok(None),
    624             }
    625         }).await
    626     }
    627 
    628     pub async fn get_comments_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Comment>, AppError> {
    629         self.with_read(move |conn| {
    630             let mut stmt = conn.prepare_cached(
    631                 "SELECT * FROM comment WHERE created_by = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
    632             ).map_err(|e| AppError::Database(e.to_string()))?;
    633 
    634             let comments = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_comment)
    635                 .map_err(|e| AppError::Database(e.to_string()))?
    636                 .collect::<Result<Vec<_>, _>>()
    637                 .map_err(|e| AppError::Database(e.to_string()))?;
    638 
    639             Ok(comments)
    640         }).await
    641     }
    642 
    643     // ======================================================================
    644     // Vote queries
    645     // ======================================================================
    646 
    647     /// Toggle vote and return (voted, new_score). Reads the score on the
    648     /// writer connection so it sees the trigger update without a separate round-trip.
    649     pub async fn toggle_vote(&self, user_id: i64, target_type: i64, target_id: i64) -> Result<(bool, i64), AppError> {
    650         self.with_write(move |conn| {
    651             let result = conn.execute(
    652                 "INSERT INTO vote (user_id, target_type, target_id, created_at) VALUES (?1, ?2, ?3, ?4)",
    653                 params![user_id, target_type, target_id, now_epoch()],
    654             );
    655 
    656             let voted = match result {
    657                 Ok(_) => true,
    658                 Err(rusqlite::Error::SqliteFailure(err, _))
    659                     if err.code == rusqlite::ffi::ErrorCode::ConstraintViolation =>
    660                 {
    661                     conn.execute(
    662                         "DELETE FROM vote WHERE user_id = ?1 AND target_type = ?2 AND target_id = ?3",
    663                         params![user_id, target_type, target_id],
    664                     ).map_err(|e| AppError::Database(e.to_string()))?;
    665                     false
    666                 }
    667                 Err(e) => return Err(AppError::Database(e.to_string())),
    668             };
    669 
    670             // Read score on the writer — sees the trigger update immediately
    671             let table = if target_type == TARGET_TYPE_STORY { "story" } else { "comment" };
    672             let score: i64 = conn.query_row(
    673                 &format!("SELECT score FROM {} WHERE id = ?1", table),
    674                 params![target_id],
    675                 |row| row.get(0),
    676             ).map_err(|e| AppError::Database(e.to_string()))?;
    677 
    678             Ok((voted, score))
    679         }).await
    680     }
    681 
    682     pub async fn get_user_votes(&self, user_id: i64, target_type: i64, target_ids: &[i64]) -> Result<Vec<i64>, AppError> {
    683         if target_ids.is_empty() {
    684             return Ok(vec![]);
    685         }
    686 
    687         let target_ids = target_ids.to_vec();
    688         self.with_read(move |conn| {
    689             // Build placeholders for IN clause
    690             let placeholders: Vec<String> = target_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect();
    691             let sql = format!(
    692                 "SELECT target_id FROM vote WHERE user_id = ?1 AND target_type = ?2 AND target_id IN ({})",
    693                 placeholders.join(", ")
    694             );
    695 
    696             let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?;
    697 
    698             // Build params
    699             let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    700             param_values.push(Box::new(user_id));
    701             param_values.push(Box::new(target_type));
    702             for &id in &target_ids {
    703                 param_values.push(Box::new(id));
    704             }
    705             let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
    706 
    707             let voted_ids = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0))
    708                 .map_err(|e| AppError::Database(e.to_string()))?
    709                 .collect::<Result<Vec<_>, _>>()
    710                 .map_err(|e| AppError::Database(e.to_string()))?;
    711 
    712             Ok(voted_ids)
    713         }).await
    714     }
    715 
    716     // ======================================================================
    717     // Category queries
    718     // ======================================================================
    719 
    720     pub async fn get_categories(&self, limit: usize) -> Result<Vec<Category>, AppError> {
    721         self.with_read(move |conn| {
    722             let mut stmt = conn.prepare_cached("SELECT id, name FROM category LIMIT ?1")
    723                 .map_err(|e| AppError::Database(e.to_string()))?;
    724 
    725             let categories = stmt.query_map(params![limit as i64], |row| {
    726                 Ok(Category {
    727                     id: row.get(0)?,
    728                     name: row.get(1)?,
    729                 })
    730             })
    731             .map_err(|e| AppError::Database(e.to_string()))?
    732             .collect::<Result<Vec<_>, _>>()
    733             .map_err(|e| AppError::Database(e.to_string()))?;
    734 
    735             Ok(categories)
    736         }).await
    737     }
    738 
    739     pub async fn get_categories_for_story(&self, story_id: i64) -> Result<Vec<Category>, AppError> {
    740         self.with_read(move |conn| {
    741             let mut stmt = conn.prepare_cached(
    742                 "SELECT c.id, c.name FROM category c
    743                  JOIN story_category sc ON sc.category_id = c.id
    744                  WHERE sc.news_item_id = ?1"
    745             ).map_err(|e| AppError::Database(e.to_string()))?;
    746 
    747             let categories = stmt.query_map(params![story_id], |row| {
    748                 Ok(Category {
    749                     id: row.get(0)?,
    750                     name: row.get(1)?,
    751                 })
    752             })
    753             .map_err(|e| AppError::Database(e.to_string()))?
    754             .collect::<Result<Vec<_>, _>>()
    755             .map_err(|e| AppError::Database(e.to_string()))?;
    756 
    757             Ok(categories)
    758         }).await
    759     }
    760 
    761     pub async fn add_tag_to_story(&self, story_id: i64, category_id: i64) -> Result<(), AppError> {
    762         self.with_write(move |conn| {
    763             conn.execute(
    764                 "INSERT INTO story_category (news_item_id, category_id) VALUES (?1, ?2)",
    765                 params![story_id, category_id],
    766             ).map_err(|e| AppError::Database(e.to_string()))?;
    767             Ok(())
    768         }).await
    769     }
    770 
    771     pub async fn remove_all_tags_from_story(&self, story_id: i64) -> Result<(), AppError> {
    772         self.with_write(move |conn| {
    773             conn.execute(
    774                 "DELETE FROM story_category WHERE news_item_id = ?1",
    775                 params![story_id],
    776             ).map_err(|e| AppError::Database(e.to_string()))?;
    777             Ok(())
    778         }).await
    779     }
    780 
    781     // ======================================================================
    782     // User queries
    783     // ======================================================================
    784 
    785     fn row_to_user(row: &rusqlite::Row) -> rusqlite::Result<User> {
    786         let email_verified_int: i64 = row.get("email_verified")?;
    787         Ok(User {
    788             id: row.get("id")?,
    789             email: row.get("email")?,
    790             password_hash: row.get("password_hash")?,
    791             username: row.get("username")?,
    792             about: row.get("about")?,
    793             karma: row.get("karma")?,
    794             created_at: row.get("created_at")?,
    795             email_verified: email_verified_int != 0,
    796             verification_token: row.get("verification_token")?,
    797         })
    798     }
    799 
    800     pub async fn get_user_by_id(&self, id: i64) -> Result<Option<User>, AppError> {
    801         self.with_read(move |conn| {
    802             let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE id = ?1")
    803                 .map_err(|e| AppError::Database(e.to_string()))?;
    804 
    805             let mut rows = stmt.query_map(params![id], Self::row_to_user)
    806                 .map_err(|e| AppError::Database(e.to_string()))?;
    807 
    808             match rows.next() {
    809                 Some(Ok(user)) => Ok(Some(user)),
    810                 Some(Err(e)) => Err(AppError::Database(e.to_string())),
    811                 None => Ok(None),
    812             }
    813         }).await
    814     }
    815 
    816     pub async fn get_user_by_email(&self, email: &str) -> Result<Option<User>, AppError> {
    817         let email = email.to_string();
    818         self.with_read(move |conn| {
    819             let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE email = ?1")
    820                 .map_err(|e| AppError::Database(e.to_string()))?;
    821 
    822             let mut rows = stmt.query_map(params![email], Self::row_to_user)
    823                 .map_err(|e| AppError::Database(e.to_string()))?;
    824 
    825             match rows.next() {
    826                 Some(Ok(user)) => Ok(Some(user)),
    827                 Some(Err(e)) => Err(AppError::Database(e.to_string())),
    828                 None => Ok(None),
    829             }
    830         }).await
    831     }
    832 
    833     pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, AppError> {
    834         let username = username.to_string();
    835         self.with_read(move |conn| {
    836             let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE username = ?1")
    837                 .map_err(|e| AppError::Database(e.to_string()))?;
    838 
    839             let mut rows = stmt.query_map(params![username], Self::row_to_user)
    840                 .map_err(|e| AppError::Database(e.to_string()))?;
    841 
    842             match rows.next() {
    843                 Some(Ok(user)) => Ok(Some(user)),
    844                 Some(Err(e)) => Err(AppError::Database(e.to_string())),
    845                 None => Ok(None),
    846             }
    847         }).await
    848     }
    849 
    850     pub async fn create_user(&self, email: &str, password_hash: &str, username: &str, verification_token: Option<&str>) -> Result<User, AppError> {
    851         let email = email.to_string();
    852         let password_hash = password_hash.to_string();
    853         let username = username.to_string();
    854         let verification_token = verification_token.map(|s| s.to_string());
    855         self.with_write(move |conn| {
    856             conn.query_row(
    857                 "INSERT INTO user (email, password_hash, username, verification_token, created_at)
    858                  VALUES (?1, ?2, ?3, ?4, ?5)
    859                  RETURNING *",
    860                 params![email, password_hash, username, verification_token, now_epoch()],
    861                 Self::row_to_user,
    862             ).map_err(|e| AppError::Database(e.to_string()))
    863         }).await
    864     }
    865 
    866     pub async fn verify_email_token(&self, token: &str) -> Result<bool, AppError> {
    867         let token = token.to_string();
    868         self.with_write(move |conn| {
    869             let rows_affected = conn.execute(
    870                 "UPDATE user SET email_verified = 1, verification_token = NULL WHERE verification_token = ?1",
    871                 params![token],
    872             ).map_err(|e| AppError::Database(e.to_string()))?;
    873             Ok(rows_affected > 0)
    874         }).await
    875     }
    876 
    877     pub async fn update_verification_token(&self, user_id: i64, token: &str) -> Result<(), AppError> {
    878         let token = token.to_string();
    879         self.with_write(move |conn| {
    880             conn.execute(
    881                 "UPDATE user SET verification_token = ?1 WHERE id = ?2",
    882                 params![token, user_id],
    883             ).map_err(|e| AppError::Database(e.to_string()))?;
    884             Ok(())
    885         }).await
    886     }
    887 
    888     // ======================================================================
    889     // Session queries
    890     // ======================================================================
    891 
    892     pub async fn create_session(&self, user_id: i64) -> Result<String, AppError> {
    893         use rand::Rng;
    894         let bytes: [u8; 32] = rand::thread_rng().gen();
    895         let token: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
    896 
    897         self.with_write(move |conn| {
    898             conn.execute(
    899                 "INSERT INTO session (token, user_id, created_at) VALUES (?1, ?2, ?3)",
    900                 params![token, user_id, now_epoch()],
    901             ).map_err(|e| AppError::Database(e.to_string()))?;
    902 
    903             Ok(token)
    904         }).await
    905     }
    906 
    907     pub async fn get_session_user_id(&self, token: &str) -> Result<Option<i64>, AppError> {
    908         let token = token.to_string();
    909         self.with_read(move |conn| {
    910             let mut stmt = conn.prepare_cached("SELECT user_id FROM session WHERE token = ?1")
    911                 .map_err(|e| AppError::Database(e.to_string()))?;
    912 
    913             match stmt.query_row(params![token], |row| row.get::<_, i64>(0)) {
    914                 Ok(id) => Ok(Some(id)),
    915                 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
    916                 Err(e) => Err(AppError::Database(e.to_string())),
    917             }
    918         }).await
    919     }
    920 
    921     pub async fn delete_session(&self, token: &str) -> Result<(), AppError> {
    922         let token = token.to_string();
    923         self.with_write(move |conn| {
    924             conn.execute("DELETE FROM session WHERE token = ?1", params![token])
    925                 .map_err(|e| AppError::Database(e.to_string()))?;
    926             Ok(())
    927         }).await
    928     }
    929 
    930     pub async fn delete_user_sessions(&self, user_id: i64) -> Result<(), AppError> {
    931         self.with_write(move |conn| {
    932             conn.execute("DELETE FROM session WHERE user_id = ?1", params![user_id])
    933                 .map_err(|e| AppError::Database(e.to_string()))?;
    934             Ok(())
    935         }).await
    936     }
    937 
    938     // ======================================================================
    939     // Enrichment queries
    940     // ======================================================================
    941 
    942     pub async fn enrich_stories(&self, story_ids: &[i64]) -> Result<Vec<StoryMeta>, AppError> {
    943         if story_ids.is_empty() {
    944             return Ok(vec![]);
    945         }
    946 
    947         let story_ids = story_ids.to_vec();
    948         self.with_read(move |conn| {
    949             // Fetch author usernames
    950             let placeholders: Vec<String> = story_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
    951             let sql = format!(
    952                 "SELECT s.id, u.username FROM story s
    953                  LEFT JOIN user u ON u.id = s.created_by
    954                  WHERE s.id IN ({})",
    955                 placeholders.join(", ")
    956             );
    957 
    958             let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?;
    959             let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    960             for &id in &story_ids {
    961                 param_values.push(Box::new(id));
    962             }
    963             let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
    964 
    965             let author_map: HashMap<i64, Option<String>> = stmt
    966                 .query_map(params_ref.as_slice(), |row| {
    967                     Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?))
    968                 })
    969                 .map_err(|e| AppError::Database(e.to_string()))?
    970                 .filter_map(|r| r.ok())
    971                 .collect();
    972 
    973             // Fetch tags
    974             let tag_sql = format!(
    975                 "SELECT sc.news_item_id, c.name FROM story_category sc
    976                  JOIN category c ON c.id = sc.category_id
    977                  WHERE sc.news_item_id IN ({})",
    978                 placeholders.join(", ")
    979             );
    980 
    981             let mut tag_stmt = conn.prepare_cached(&tag_sql).map_err(|e| AppError::Database(e.to_string()))?;
    982 
    983             let mut tags_map: HashMap<i64, Vec<String>> = HashMap::new();
    984             let tag_rows = tag_stmt
    985                 .query_map(params_ref.as_slice(), |row| {
    986                     Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?))
    987                 })
    988                 .map_err(|e| AppError::Database(e.to_string()))?;
    989 
    990             for row in tag_rows {
    991                 if let Ok((story_id, Some(name))) = row {
    992                     tags_map.entry(story_id).or_default().push(name);
    993                 }
    994             }
    995 
    996             let result: Vec<StoryMeta> = story_ids
    997                 .iter()
    998                 .map(|&sid| StoryMeta {
    999                     story_id: sid,
   1000                     author_username: author_map.get(&sid).cloned().flatten(),
   1001                     tags: tags_map.get(&sid).cloned().unwrap_or_default(),
   1002                 })
   1003                 .collect();
   1004 
   1005             Ok(result)
   1006         }).await
   1007     }
   1008 
   1009     pub async fn enrich_comment_authors(&self, user_ids: &[i64]) -> Result<HashMap<i64, String>, AppError> {
   1010         if user_ids.is_empty() {
   1011             return Ok(HashMap::new());
   1012         }
   1013 
   1014         let user_ids = user_ids.to_vec();
   1015         self.with_read(move |conn| {
   1016             let placeholders: Vec<String> = user_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
   1017             let sql = format!(
   1018                 "SELECT id, username FROM user WHERE id IN ({})",
   1019                 placeholders.join(", ")
   1020             );
   1021 
   1022             let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?;
   1023             let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
   1024             for &id in &user_ids {
   1025                 param_values.push(Box::new(id));
   1026             }
   1027             let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
   1028 
   1029             let map: HashMap<i64, String> = stmt
   1030                 .query_map(params_ref.as_slice(), |row| {
   1031                     Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
   1032                 })
   1033                 .map_err(|e| AppError::Database(e.to_string()))?
   1034                 .filter_map(|r| r.ok())
   1035                 .collect();
   1036 
   1037             Ok(map)
   1038         }).await
   1039     }
   1040 
   1041 }
   1042 
   1043 // ==========================================================================
   1044 // Helper functions
   1045 // ==========================================================================
   1046 
   1047 pub fn now_epoch() -> i64 {
   1048     std::time::SystemTime::now()
   1049         .duration_since(std::time::UNIX_EPOCH)
   1050         .unwrap()
   1051         .as_secs() as i64
   1052 }
   1053 
   1054 pub fn hot_score(points: i64, published: i64) -> f64 {
   1055     let age_hours = (now_epoch() - published) as f64 / 3600.0;
   1056     (points as f64) / (age_hours + 2.0).powf(1.8)
   1057 }
   1058 
   1059 pub fn time_ago(timestamp: i64) -> String {
   1060     let elapsed = now_epoch() - timestamp;
   1061 
   1062     let days = elapsed / 86400;
   1063     let hours = elapsed / 3600;
   1064     let minutes = elapsed / 60;
   1065 
   1066     if days > 0 {
   1067         if days == 1 { "1 day ago".to_string() }
   1068         else { format!("{days} days ago") }
   1069     } else if hours > 0 {
   1070         if hours == 1 { "1 hour ago".to_string() }
   1071         else { format!("{hours} hours ago") }
   1072     } else if minutes > 0 {
   1073         if minutes == 1 { "1 minute ago".to_string() }
   1074         else { format!("{minutes} minutes ago") }
   1075     } else {
   1076         "just now".to_string()
   1077     }
   1078 }
   1079 
   1080 /// Extract domain from URL
   1081 pub fn extract_domain(url_str: &str) -> Option<String> {
   1082     url_str
   1083         .split("//")
   1084         .nth(1)
   1085         .and_then(|rest| rest.split('/').next())
   1086         .map(|host| host.trim_start_matches("www.").to_string())
   1087 }