bugfix: bookmark chapter links use chapter number, not UUID

The reader route is keyed on chapter number (URL `/manga/{id}/chapter/{n}`,
loaded via `Number(params.n)`), but the bookmarks list was building
hrefs from `chapter_id` (a UUID). Following any chapter bookmark
produced a NaN load on the reader page.

Fix at the API layer so every consumer of /me/bookmarks gets the
information without a follow-up round-trip per bookmark.

- domain::BookmarkSummary: new type, `Bookmark` plus
  `chapter_number: Option<i32>`. Populated by a LEFT JOIN on chapters
  so manga-level bookmarks come back with `chapter_number = null` and
  chapter-level ones get the value. `Bookmark` itself stays minimal
  for POST / DELETE responses.
- repo::bookmark::list_for_user returns Vec<BookmarkSummary>.
- api::bookmarks::list_me returns PagedResponse<BookmarkSummary>.
- Frontend `Bookmark` type carries an optional `chapter_number`.
- /bookmarks page builds `/manga/{manga_id}/chapter/{chapter_number}`
  for chapter bookmarks, falling back to the manga overview if the
  chapter has been deleted out from under the bookmark (chapter_id is
  ON DELETE SET NULL, so this is a real edge case).

New test asserts both branches of the JOIN: a chapter-level bookmark
comes back with the right chapter_number and page, a manga-level one
has a null chapter_number.

Lockstep version bump to 0.9.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 23:20:45 +02:00
parent ea60bd97de
commit 563524d51e
10 changed files with 131 additions and 16 deletions

View File

@@ -12,7 +12,7 @@ use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::Bookmark;
use crate::domain::{Bookmark, BookmarkSummary};
use crate::error::{AppError, AppResult};
use crate::repo;
@@ -95,7 +95,7 @@ async fn list_me(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<Bookmark>>> {
) -> AppResult<Json<PagedResponse<BookmarkSummary>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let items = repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?;

View File

@@ -12,3 +12,19 @@ pub struct Bookmark {
pub page: Option<i32>,
pub created_at: DateTime<Utc>,
}
/// `Bookmark` enriched with the chapter's reader-facing number, so the
/// frontend can build `/manga/{id}/chapter/{number}` links without an
/// extra round-trip per bookmark. Populated by a LEFT JOIN; `null` for
/// manga-level bookmarks or for chapter bookmarks whose chapter has
/// since been deleted (`chapter_id` is `ON DELETE SET NULL`).
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct BookmarkSummary {
pub id: Uuid,
pub user_id: Uuid,
pub manga_id: Uuid,
pub chapter_id: Option<Uuid>,
pub chapter_number: Option<i32>,
pub page: Option<i32>,
pub created_at: DateTime<Utc>,
}

View File

@@ -7,7 +7,7 @@ pub mod session;
pub mod user;
pub use api_token::ApiToken;
pub use bookmark::Bookmark;
pub use bookmark::{Bookmark, BookmarkSummary};
pub use chapter::Chapter;
pub use manga::Manga;
pub use page::Page;

View File

@@ -3,7 +3,7 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::Bookmark;
use crate::domain::{Bookmark, BookmarkSummary};
use crate::error::{AppError, AppResult};
pub async fn create(
@@ -36,18 +36,31 @@ pub async fn create(
}
}
/// 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<Bookmark>> {
let rows = sqlx::query_as::<_, Bookmark>(
) -> AppResult<Vec<BookmarkSummary>> {
let rows = sqlx::query_as::<_, BookmarkSummary>(
r#"
SELECT id, user_id, manga_id, chapter_id, page, created_at
FROM bookmarks
WHERE user_id = $1
ORDER BY created_at DESC
SELECT
b.id,
b.user_id,
b.manga_id,
b.chapter_id,
c.number AS chapter_number,
b.page,
b.created_at
FROM bookmarks b
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
"#,
)