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 }