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>
135 lines
4.2 KiB
Rust
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)))
|
|
}
|