feat: manga collections (0.17.0)
User-owned named lists of mangas with an add-to-collection modal on the manga page and dedicated /collections and /collections/:id pages. - Schema (0010): `collections` (per-user case-insensitive name uniqueness) + `collection_mangas` join with cascade FKs. - Endpoints: full CRUD on `/v1/collections`, idempotent add/remove for `/v1/collections/:id/mangas`, and `/v1/mangas/:id/my-collections` for the modal's pre-checked state. Owner-mismatch surfaces as 404 (not 403) so the API doesn't disclose collection existence to non-owners; the frontend funnels 401 to /login. Three-state PATCH via a new shared `domain::patch::Patch<T>` lets clients distinguish "leave alone", "clear", and "set" for description. - Frontend: reusable `Modal` component (focus trap, opt-in backdrop close, ESC) and `AddToCollectionModal` with optimistic toggling that's race-safe under fast clicks. /collections page renders cover-collage cards; /collections/:id is editable with per-card remove. Top nav gets a Collections link. 155 backend tests (incl. 21 collection tests covering ownership, idempotence, sample-cover enrichment, three-state PATCH, FK race); 88 frontend tests; svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
247
backend/src/api/collections.rs
Normal file
247
backend/src/api/collections.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::collection::{
|
||||
Collection, CollectionPatch, CollectionSummary, NewCollection,
|
||||
};
|
||||
use crate::domain::manga::Manga;
|
||||
use crate::domain::patch::Patch;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/collections", post(create))
|
||||
.route("/me/collections", get(list_mine))
|
||||
.route("/collections/:id", get(get_one).patch(update).delete(delete_one))
|
||||
.route("/collections/:id/mangas", get(list_mangas).post(add_manga))
|
||||
.route(
|
||||
"/collections/:id/mangas/:manga_id",
|
||||
delete(remove_manga),
|
||||
)
|
||||
.route(
|
||||
"/mangas/:id/my-collections",
|
||||
get(list_my_collections_containing),
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListParams {
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
DEFAULT_LIMIT
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddMangaBody {
|
||||
pub manga_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MangaCollectionIds {
|
||||
pub collection_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> AppResult<()> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "name is required".into(),
|
||||
details: json!({ "name": "required" }),
|
||||
});
|
||||
}
|
||||
if trimmed.chars().count() > MAX_NAME_LEN {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "name too long".into(),
|
||||
details: json!({ "name": format!("max {MAX_NAME_LEN} characters") }),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_description(desc: Option<&str>) -> AppResult<()> {
|
||||
if let Some(d) = desc {
|
||||
if d.chars().count() > MAX_DESCRIPTION_LEN {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "description too long".into(),
|
||||
details: json!({ "description": format!("max {MAX_DESCRIPTION_LEN} characters") }),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(input): Json<NewCollection>,
|
||||
) -> AppResult<(StatusCode, Json<Collection>)> {
|
||||
validate_name(&input.name)?;
|
||||
validate_description(input.description.as_deref())?;
|
||||
let row = repo::collection::create(
|
||||
&state.db,
|
||||
user.id,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(row)))
|
||||
}
|
||||
|
||||
async fn list_mine(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> AppResult<Json<PagedResponse<CollectionSummary>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) =
|
||||
repo::collection::list_for_user(&state.db, user.id, limit, offset).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn get_one(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<Collection>> {
|
||||
let row = require_owner(&state, user.id, id).await?;
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(patch): Json<CollectionPatch>,
|
||||
) -> AppResult<Json<Collection>> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
if let Some(ref n) = patch.name {
|
||||
validate_name(n)?;
|
||||
}
|
||||
if let Patch::Set(ref d) = patch.description {
|
||||
validate_description(Some(d.as_str()))?;
|
||||
}
|
||||
// Three-state semantics via `Patch<T>`: omitted → Unchanged
|
||||
// (column untouched), explicit `null` → Clear (NULL), value → Set.
|
||||
let description_provided = patch.description.is_provided();
|
||||
let description_value: Option<&str> = match &patch.description {
|
||||
Patch::Set(s) => Some(s.as_str()),
|
||||
Patch::Clear | Patch::Unchanged => None,
|
||||
};
|
||||
let updated = repo::collection::update(
|
||||
&state.db,
|
||||
id,
|
||||
patch.name.as_deref(),
|
||||
description_provided,
|
||||
description_value,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
async fn delete_one(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
repo::collection::delete(&state.db, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_mangas(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> AppResult<Json<PagedResponse<Manga>>> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) =
|
||||
repo::collection::list_mangas(&state.db, id, limit, offset).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn add_manga(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<AddMangaBody>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, id).await?;
|
||||
if !repo::manga::exists(&state.db, body.manga_id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let created = repo::collection::add_manga(&state.db, id, body.manga_id).await?;
|
||||
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
|
||||
}
|
||||
|
||||
async fn remove_manga(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path((collection_id, manga_id)): Path<(Uuid, Uuid)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
require_owner_id(&state, user.id, collection_id).await?;
|
||||
repo::collection::remove_manga(&state.db, collection_id, manga_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_my_collections_containing(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
) -> AppResult<Json<MangaCollectionIds>> {
|
||||
// No 404 if the manga doesn't exist — the empty list is the
|
||||
// correct answer ("you have it in zero of your collections") and
|
||||
// keeps the request side-effect-free.
|
||||
let ids =
|
||||
repo::collection::list_collections_containing(&state.db, user.id, manga_id).await?;
|
||||
Ok(Json(MangaCollectionIds { collection_ids: ids }))
|
||||
}
|
||||
|
||||
/// Returns the row iff the caller owns it. Both "doesn't exist" and
|
||||
/// "exists but belongs to someone else" surface as `NotFound` so the
|
||||
/// API doesn't disclose collection existence to non-owners — the
|
||||
/// frontend already does this funnelling for URLs, and consistency at
|
||||
/// the API matters because the same identifiers travel through bots
|
||||
/// and shared links.
|
||||
async fn require_owner(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AppResult<Collection> {
|
||||
match repo::collection::get(&state.db, id).await {
|
||||
Ok(row) if row.user_id == user_id => Ok(row),
|
||||
// Either the row doesn't exist (NotFound from `get`) or it
|
||||
// belongs to someone else — both collapse to NotFound.
|
||||
Ok(_) | Err(AppError::NotFound) => Err(AppError::NotFound),
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_owner_id(state: &AppState, user_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
match repo::collection::find_owner(&state.db, id).await? {
|
||||
Some(owner) if owner == user_id => Ok(()),
|
||||
// Same non-leakage rationale as `require_owner` above.
|
||||
_ => Err(AppError::NotFound),
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ use uuid::Uuid;
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga, Patch};
|
||||
use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga};
|
||||
use crate::domain::patch::Patch;
|
||||
use crate::domain::tag::TagRef;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod auth;
|
||||
pub mod authors;
|
||||
pub mod bookmarks;
|
||||
pub mod chapters;
|
||||
pub mod collections;
|
||||
pub mod files;
|
||||
pub mod genres;
|
||||
pub mod health;
|
||||
@@ -24,4 +25,5 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(genres::routes())
|
||||
.merge(tags::routes())
|
||||
.merge(authors::routes())
|
||||
.merge(collections::routes())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user