//! Bookmark persistence. use sqlx::PgPool; use uuid::Uuid; use crate::domain::{Bookmark, BookmarkSummary}; use crate::error::{AppError, AppResult}; pub async fn create( pool: &PgPool, user_id: Uuid, manga_id: Uuid, chapter_id: Option, page: Option, ) -> AppResult { let result = sqlx::query_as::<_, Bookmark>( r#" INSERT INTO bookmarks (user_id, manga_id, chapter_id, page) VALUES ($1, $2, $3, $4) RETURNING id, user_id, manga_id, chapter_id, page, created_at "#, ) .bind(user_id) .bind(manga_id) .bind(chapter_id) .bind(page) .fetch_one(pool) .await; match result { Ok(b) => Ok(b), Err(e) if is_unique_violation(&e) => Err(AppError::Conflict( "bookmark already exists for this manga/chapter".into(), )), Err(e) => Err(AppError::Database(e)), } } /// Returns the user's bookmarks enriched with each chapter's number /// (LEFT JOINed; `chapter_number` is `null` for manga-level bookmarks /// or for chapter bookmarks whose chapter has since been deleted — /// `bookmarks.chapter_id` is `ON DELETE SET NULL`). The frontend uses /// the number to build reader URLs, which are keyed on number, not id. pub async fn list_for_user( pool: &PgPool, user_id: Uuid, limit: i64, offset: i64, ) -> AppResult<(Vec, i64)> { let rows = sqlx::query_as::<_, BookmarkSummary>( r#" SELECT b.id, b.user_id, b.manga_id, m.title AS manga_title, m.cover_image_path AS manga_cover_image_path, b.chapter_id, c.number AS chapter_number, b.page, b.created_at FROM bookmarks b INNER JOIN mangas m ON m.id = b.manga_id LEFT JOIN chapters c ON c.id = b.chapter_id WHERE b.user_id = $1 ORDER BY b.created_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(user_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let (total,): (i64,) = sqlx::query_as("SELECT count(*) FROM bookmarks WHERE user_id = $1") .bind(user_id) .fetch_one(pool) .await?; Ok((rows, total)) } pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult> { let row: Option<(Uuid,)> = sqlx::query_as("SELECT user_id FROM bookmarks WHERE id = $1") .bind(id) .fetch_optional(pool) .await?; Ok(row.map(|(uid,)| uid)) } pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> { sqlx::query("DELETE FROM bookmarks WHERE id = $1") .bind(id) .execute(pool) .await?; Ok(()) } fn is_unique_violation(err: &sqlx::Error) -> bool { if let sqlx::Error::Database(db_err) = err { db_err.code().as_deref() == Some("23505") } else { false } }