Files
Mangalord/backend/src/api/bookmarks.rs
MechaCat02 c320eda7cd chore: dedupe is_unique_violation, lift SQL into repo, centralise URL parsing
Three layering cleanups from REVIEW.md §5 / §3:

- Drop the three private `is_unique_violation` helpers in
  repo::{user,chapter,bookmark} in favour of sqlx 0.8's
  `DatabaseError::is_unique_violation()` method (already used by
  repo::collection).
- Remove the unreachable 23505 branch in repo::chapter::create — the
  (manga_id, number) UNIQUE was dropped in 0013, so the defensive arm
  could no longer fire. A doc note records what to do if uniqueness
  is re-added.
- Move three inline SQL queries out of handlers/daemon into repo
  functions: bookmarks' chapter-belongs-to-manga guard
  (`repo::chapter::belongs_to_manga`), the daemon's dispatch lookup
  (`repo::chapter::dispatch_target`), and the daemon's page_count
  safety net (`repo::chapter::page_count`). Restores the
  handlers→repo layering invariant in CLAUDE.md.
- New `crawler::url_utils` module consolidates host_of / origin_of /
  registrable_domain — they used to live in three crawler submodules
  with diverging edge-case behaviour. Tests moved with them.
- Doc cross-references on repo::author::set_for_manga and
  repo::genre::set_for_manga pointing to the crawler's name-keyed
  variants, so the intentional duplication is discoverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:24:05 +02:00

135 lines
4.2 KiB
Rust

//! 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 serde_json::json;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::crawler::pipeline;
use crate::domain::{Bookmark, BookmarkSummary};
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>)> {
// Reject obviously-bad page numbers up front (0-based or negative
// page indexes were silently accepted before; not exploitable but
// not what callers mean).
if let Some(p) = input.page {
if p < 1 {
return Err(AppError::ValidationFailed {
message: "page must be 1 or greater".into(),
details: json!({ "page": "must be >= 1" }),
});
}
}
// 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 {
if !repo::chapter::belongs_to_manga(&state.db, chapter_id, input.manga_id).await? {
return Err(AppError::NotFound);
}
}
let bookmark = repo::bookmark::create(
&state.db,
user.id,
input.manga_id,
input.chapter_id,
input.page,
)
.await?;
// Fire-and-forget: kick off content syncs for any pending chapters of
// the newly-bookmarked manga. The dedup index makes this idempotent
// across repeated bookmarks of the same manga; failure here must not
// surface to the user (the daily cron sweeps anything missed).
let pool = state.db.clone();
let manga_id = input.manga_id;
tokio::spawn(async move {
match pipeline::enqueue_pending_for_manga(&pool, manga_id).await {
Ok(summary) => tracing::info!(
%manga_id,
inserted = summary.inserted,
skipped = summary.skipped,
failed = summary.failed,
"bookmark hook: enqueued pending chapters"
),
Err(e) => tracing::warn!(
%manga_id, error = ?e,
"bookmark hook: enqueue_pending_for_manga failed"
),
}
});
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<BookmarkSummary>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}