The profile overview's bookmark counter showed 0 even when the user had bookmarks because /me/bookmarks left page.total null. Repo now returns the count alongside the rows; handler uses with_total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
2.9 KiB
Rust
107 lines
2.9 KiB
Rust
//! 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<Uuid>,
|
|
page: Option<i32>,
|
|
) -> AppResult<Bookmark> {
|
|
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<BookmarkSummary>, 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<Option<Uuid>> {
|
|
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
|
|
}
|
|
}
|