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:
103
backend/src/api/bookmarks.rs
Normal file
103
backend/src/api/bookmarks.rs
Normal 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)))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user