feat: bookmarks (CRUD + per-user listing + frontend toggle)

Backend:
- Migration 0004_bookmarks_unique.sql adds a partial unique index on
  (user_id, manga_id) WHERE chapter_id IS NULL. The 0001 UNIQUE
  constraint over (user_id, manga_id, chapter_id) doesn't block dupes
  when chapter_id is NULL under Postgres's default NULLS DISTINCT, so a
  user could otherwise bookmark the same manga twice at the manga
  level. Chapter-level dupes are still caught by the 0001 constraint.
- repo::bookmark with create / list_for_user / find_owner / delete.
  create catches the 23505 unique violation and surfaces it as
  AppError::Conflict so handlers return a clean 409.
- POST /api/v1/bookmarks { manga_id, chapter_id?, page? } — CurrentUser
  required. Pre-validates the manga exists (404 if not) and, when
  chapter_id is supplied, that the chapter belongs to that manga (also
  404), so FK violations can't bubble up as 500s.
- DELETE /api/v1/bookmarks/{id} — owner-only. 404 if unknown, 403 if it
  exists for another user, 204 on success. Idempotent: deleting an
  already-deleted bookmark is 404, not 500.
- GET /api/v1/me/bookmarks — paged envelope, sorted by created_at DESC,
  scoped to the current user so the URL itself can't be used to peek at
  someone else's bookmarks.

Integration coverage in tests/api_bookmarks.rs (9 cases): create+list
returns only own; duplicate manga-level bookmark → 409; unknown manga
→ 404; unauthenticated POST → 401; user A cannot delete user B's
bookmark (403); unknown delete → 404; double-delete → 404, not 500;
/me/bookmarks requires auth; paged envelope shape on empty list.

Frontend:
- lib/api/bookmarks.ts with createBookmark / deleteBookmark /
  listMyBookmarks. listMyBookmarksOrEmpty wraps the 401 case so pages
  can render anonymously without try/catch boilerplate.
- /manga/[id] overview: pre-loads the user's bookmark list in its load
  function and renders either:
  - "★ Bookmarked" / "☆ Bookmark" toggle with aria-pressed when authed;
    click POSTs or DELETEs and mutates a local working copy of the
    bookmark list (optimistic UI without re-fetching);
  - or a "Sign in to bookmark" link for anonymous users.
- /bookmarks page lists the current user's bookmarks (chapter-level
  bookmarks link into the reader, manga-level back to the overview).
  Anonymous users see a sign-in prompt instead of a 401 page.

E2E in e2e/bookmarks.spec.ts (3 cases): authed toggle round-trip
(bookmark, see in /bookmarks list, unbookmark); anonymous user gets the
sign-in CTA on the overview; anonymous /bookmarks shows the sign-in
prompt. Existing reader.spec.ts updated for the new
bookmark-signin/toggle test IDs.

Lockstep version bump to 0.7.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 22:40:27 +02:00
parent 9af070608b
commit e92c581c7b
17 changed files with 927 additions and 17 deletions

View File

@@ -0,0 +1,103 @@
//! Bookmarks — owned by a `CurrentUser`. Reads + writes both require
//! auth; the listing endpoint is scoped under `/me/bookmarks` so the
//! URL itself can't be reused to peek at another user's bookmarks.
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::Deserialize;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::Bookmark;
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/bookmarks", post(create))
.route("/bookmarks/:id", delete(delete_one))
.route("/me/bookmarks", get(list_me))
}
#[derive(Debug, Deserialize)]
pub struct NewBookmark {
pub manga_id: Uuid,
#[serde(default)]
pub chapter_id: Option<Uuid>,
#[serde(default)]
pub page: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
50
}
async fn create(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<NewBookmark>,
) -> AppResult<(StatusCode, Json<Bookmark>)> {
// Surface 404 on a non-existent manga / chapter rather than letting
// the foreign-key violation collapse into a generic 500.
repo::manga::get(&state.db, input.manga_id).await?;
if let Some(chapter_id) = input.chapter_id {
let exists: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM chapters WHERE id = $1 AND manga_id = $2",
)
.bind(chapter_id)
.bind(input.manga_id)
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound);
}
}
let bookmark = repo::bookmark::create(
&state.db,
user.id,
input.manga_id,
input.chapter_id,
input.page,
)
.await?;
Ok((StatusCode::CREATED, Json(bookmark)))
}
async fn delete_one(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
match repo::bookmark::find_owner(&state.db, id).await? {
None => Err(AppError::NotFound),
Some(owner) if owner != user.id => Err(AppError::Forbidden),
Some(_) => {
repo::bookmark::delete(&state.db, id).await?;
Ok(StatusCode::NO_CONTENT)
}
}
}
async fn list_me(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<Bookmark>>> {
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?;
Ok(Json(PagedResponse::new(items, limit, offset)))
}

View File

@@ -1,4 +1,5 @@
pub mod auth;
pub mod bookmarks;
pub mod chapters;
pub mod files;
pub mod health;
@@ -16,4 +17,5 @@ pub fn routes() -> Router<AppState> {
.merge(chapters::routes())
.merge(files::routes())
.merge(auth::routes())
.merge(bookmarks::routes())
}

View File

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

View File

@@ -1,4 +1,5 @@
pub mod api_token;
pub mod bookmark;
pub mod chapter;
pub mod manga;
pub mod page;