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

2
backend/Cargo.lock generated
View File

@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.9.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.9.1" version = "0.9.2"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -12,7 +12,7 @@ use uuid::Uuid;
use crate::api::pagination::PagedResponse; use crate::api::pagination::PagedResponse;
use crate::app::AppState; use crate::app::AppState;
use crate::auth::extractor::CurrentUser; use crate::auth::extractor::CurrentUser;
use crate::domain::Bookmark; use crate::domain::{Bookmark, BookmarkSummary};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::repo; use crate::repo;
@@ -95,7 +95,7 @@ async fn list_me(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>, Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<Bookmark>>> { ) -> AppResult<Json<PagedResponse<BookmarkSummary>>> {
let limit = params.limit.clamp(1, 200); let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0); let offset = params.offset.max(0);
let items = repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?; 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 page: Option<i32>,
pub created_at: DateTime<Utc>, 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 mod user;
pub use api_token::ApiToken; pub use api_token::ApiToken;
pub use bookmark::Bookmark; pub use bookmark::{Bookmark, BookmarkSummary};
pub use chapter::Chapter; pub use chapter::Chapter;
pub use manga::Manga; pub use manga::Manga;
pub use page::Page; pub use page::Page;

View File

@@ -3,7 +3,7 @@
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::domain::Bookmark; use crate::domain::{Bookmark, BookmarkSummary};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
pub async fn create( 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( pub async fn list_for_user(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> AppResult<Vec<Bookmark>> { ) -> AppResult<Vec<BookmarkSummary>> {
let rows = sqlx::query_as::<_, Bookmark>( let rows = sqlx::query_as::<_, BookmarkSummary>(
r#" r#"
SELECT id, user_id, manga_id, chapter_id, page, created_at SELECT
FROM bookmarks b.id,
WHERE user_id = $1 b.user_id,
ORDER BY created_at DESC 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 LIMIT $2 OFFSET $3
"#, "#,
) )

View File

@@ -221,6 +221,81 @@ async fn list_me_requires_authentication(pool: PgPool) {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
} }
#[sqlx::test(migrations = "./migrations")]
async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
// Seed a chapter directly so we know its number without uploading pages.
mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"))
.await
.unwrap();
// Look up its id so we can bookmark it.
let chapter_id: Uuid = sqlx::query_scalar(
"SELECT id FROM chapters WHERE manga_id = $1 AND number = $2",
)
.bind(manga_id)
.bind(7_i32)
.fetch_one(&pool)
.await
.unwrap();
// Bookmark the chapter at page 4.
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({
"manga_id": manga_id.to_string(),
"chapter_id": chapter_id.to_string(),
"page": 4
}),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
// Also bookmark the manga at the manga level — chapter_number must
// come back null for it, populated for the chapter one.
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/bookmarks",
json!({ "manga_id": manga_id.to_string() }),
&cookie,
))
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/me/bookmarks", &cookie))
.await
.unwrap();
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 2);
let chapter_bookmark = items
.iter()
.find(|b| !b["chapter_id"].is_null())
.expect("chapter-level bookmark present");
assert_eq!(chapter_bookmark["chapter_number"], 7);
assert_eq!(chapter_bookmark["page"], 4);
let manga_bookmark = items
.iter()
.find(|b| b["chapter_id"].is_null())
.expect("manga-level bookmark present");
assert!(
manga_bookmark["chapter_number"].is_null(),
"manga-level bookmark must have a null chapter_number"
);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn list_me_returns_paged_envelope(pool: PgPool) { async fn list_me_returns_paged_envelope(pool: PgPool) {
let h = common::harness(pool); let h = common::harness(pool);

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.9.1", "version": "0.9.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -5,6 +5,12 @@ export type Bookmark = {
user_id: string; user_id: string;
manga_id: string; manga_id: string;
chapter_id: string | null; chapter_id: string | null;
/**
* Reader-facing chapter number, populated by the backend's LEFT
* JOIN when listing. `null` for manga-level bookmarks and for
* chapter bookmarks whose chapter has been deleted.
*/
chapter_number?: number | null;
page: number | null; page: number | null;
created_at: string; created_at: string;
}; };

View File

@@ -20,11 +20,16 @@
<ul class="bookmark-list" data-testid="bookmark-list"> <ul class="bookmark-list" data-testid="bookmark-list">
{#each bookmarks as b (b.id)} {#each bookmarks as b (b.id)}
<li> <li>
{#if b.chapter_id} {#if b.chapter_id && b.chapter_number != null}
<a href="/manga/{b.manga_id}/chapter/{b.chapter_id}"> <a href="/manga/{b.manga_id}/chapter/{b.chapter_number}">
Chapter bookmark Chapter {b.chapter_number}
{#if b.page}— page {b.page}{/if} {#if b.page}— page {b.page}{/if}
</a> </a>
{:else if b.chapter_id}
<!-- Chapter bookmark whose chapter was deleted; fall
back to the manga overview rather than emit a
broken link to a number we don't have. -->
<a href="/manga/{b.manga_id}">Chapter bookmark (chapter removed)</a>
{:else} {:else}
<a href="/manga/{b.manga_id}">Manga bookmark</a> <a href="/manga/{b.manga_id}">Manga bookmark</a>
{/if} {/if}