diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8ee8ee8..a3e789b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 96b0aa7..55e6e24 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.14.0" +version = "0.15.0" edition = "2021" [lib] diff --git a/backend/migrations/0009_manga_metadata.sql b/backend/migrations/0009_manga_metadata.sql new file mode 100644 index 0000000..763dea7 --- /dev/null +++ b/backend/migrations/0009_manga_metadata.sql @@ -0,0 +1,102 @@ +-- Manga metadata: status, alternative titles, normalized authors, +-- curated genres, and user-extensible tags. Each new concept gets its +-- own table joined to mangas rather than being jammed onto the mangas +-- row, in line with the extension philosophy in 0001_init.sql. + +-- 1. Inline columns: status (curated enum-like) and alt_titles (array). +ALTER TABLE mangas + ADD COLUMN status text NOT NULL DEFAULT 'ongoing' + CHECK (status IN ('ongoing', 'completed')), + ADD COLUMN alt_titles text[] NOT NULL DEFAULT '{}'; + +-- 2. Authors: normalized so an author page can list works. +-- Display name preserves casing; lookups go through lower(name) for +-- case-insensitive de-dup. Trigram index supports autocomplete. +CREATE TABLE authors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX authors_name_lower_uniq ON authors (lower(name)); +CREATE INDEX authors_name_trgm_idx ON authors USING gin (name gin_trgm_ops); + +CREATE TABLE manga_authors ( + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + author_id uuid NOT NULL REFERENCES authors(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + PRIMARY KEY (manga_id, author_id) +); +CREATE INDEX manga_authors_author_idx ON manga_authors (author_id); + +-- 3. Genres: curated controlled vocabulary, seeded below. Uploaders +-- pick from this list; users do not add new genres (that's what +-- tags are for). +CREATE TABLE genres ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL UNIQUE +); + +INSERT INTO genres (name) VALUES + ('Action'), + ('Adventure'), + ('Comedy'), + ('Drama'), + ('Fantasy'), + ('Horror'), + ('Mystery'), + ('Romance'), + ('Sci-Fi'), + ('Slice of Life'), + ('Sports'), + ('Supernatural'), + ('Thriller') +ON CONFLICT DO NOTHING; + +CREATE TABLE manga_genres ( + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + genre_id uuid NOT NULL REFERENCES genres(id) ON DELETE CASCADE, + PRIMARY KEY (manga_id, genre_id) +); +CREATE INDEX manga_genres_genre_idx ON manga_genres (genre_id); + +-- 4. Tags: free-form but globally shared. Any signed-in user can +-- attach a tag to a manga — creating the tag if new. `added_by` +-- records the attacher so the UI can show a remove button only to +-- them. ON DELETE SET NULL keeps the tag intact if the user leaves. +CREATE TABLE tags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX tags_name_lower_uniq ON tags (lower(name)); +CREATE INDEX tags_name_trgm_idx ON tags USING gin (name gin_trgm_ops); + +CREATE TABLE manga_tags ( + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + added_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (manga_id, tag_id) +); +CREATE INDEX manga_tags_tag_idx ON manga_tags (tag_id); + +-- 5. Backfill the existing single `author` column into the normalized +-- tables. Trim whitespace; skip blanks. After backfill, drop the +-- trigram index on the old column and then drop the column itself. +INSERT INTO authors (name) + SELECT DISTINCT trim(author) + FROM mangas + WHERE author IS NOT NULL + AND trim(author) <> '' +ON CONFLICT (lower(name)) DO NOTHING; + +INSERT INTO manga_authors (manga_id, author_id, position) + SELECT m.id, a.id, 0 + FROM mangas m + JOIN authors a ON lower(a.name) = lower(trim(m.author)) + WHERE m.author IS NOT NULL + AND trim(m.author) <> '' +ON CONFLICT DO NOTHING; + +DROP INDEX IF EXISTS mangas_author_trgm_idx; +ALTER TABLE mangas DROP COLUMN author; diff --git a/backend/src/api/genres.rs b/backend/src/api/genres.rs new file mode 100644 index 0000000..5511689 --- /dev/null +++ b/backend/src/api/genres.rs @@ -0,0 +1,16 @@ +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; + +use crate::app::AppState; +use crate::domain::genre::Genre; +use crate::error::AppResult; +use crate::repo; + +pub fn routes() -> Router { + Router::new().route("/genres", get(list)) +} + +async fn list(State(state): State) -> AppResult>> { + Ok(Json(repo::genre::list_all(&state.db).await?)) +} diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 07d41a0..d842760 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -1,6 +1,6 @@ use axum::extract::{Multipart, Path, Query, State}; use axum::http::StatusCode; -use axum::routing::get; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; use serde::Deserialize; use serde_json::json; @@ -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::{Manga, NewManga}; +use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga, Patch}; +use crate::domain::tag::TagRef; use crate::error::{AppError, AppResult}; use crate::repo; use crate::upload::{parse_image, UploadedImage}; @@ -17,13 +18,25 @@ use crate::upload::{parse_image, UploadedImage}; pub fn routes() -> Router { Router::new() .route("/mangas", get(list).post(create)) - .route("/mangas/:id", get(get_one)) + .route("/mangas/:id", get(get_one).patch(update)) + .route("/mangas/:id/tags", post(attach_tag)) + .route("/mangas/:id/tags/:tag_id", delete(detach_tag)) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Default)] pub struct ListParams { #[serde(default)] pub search: Option, + #[serde(default)] + pub status: Option, + /// Comma-separated UUIDs. AND across the list — every id must be + /// attached to the manga for it to match. + #[serde(default)] + pub author_id: Option, + #[serde(default)] + pub genre_id: Option, + #[serde(default)] + pub tag_id: Option, #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] @@ -36,41 +49,78 @@ fn default_limit() -> i64 { 50 } +fn parse_uuid_csv(field: &str, raw: Option<&str>) -> AppResult> { + let Some(raw) = raw else { return Ok(Vec::new()) }; + let mut out = Vec::new(); + for piece in raw.split(',') { + let s = piece.trim(); + if s.is_empty() { + continue; + } + let id = Uuid::parse_str(s).map_err(|_| AppError::ValidationFailed { + message: format!("{field} contained an invalid UUID"), + details: json!({ field: "comma-separated UUIDs" }), + })?; + out.push(id); + } + Ok(out) +} + async fn list( State(state): State, Query(params): Query, -) -> AppResult>> { +) -> AppResult>> { let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); + let status = match params.status.as_deref().map(str::trim) { + None | Some("") => None, + Some(s) => { + if !repo::manga::STATUSES.contains(&s) { + return Err(AppError::ValidationFailed { + message: "invalid status filter".into(), + details: json!({ "status": format!("must be one of {:?}", repo::manga::STATUSES) }), + }); + } + Some(s.to_string()) + } + }; let q = repo::manga::ListQuery { search: params.search.filter(|s| !s.trim().is_empty()), + status, + author_ids: parse_uuid_csv("author_id", params.author_id.as_deref())?, + genre_ids: parse_uuid_csv("genre_id", params.genre_id.as_deref())?, + tag_ids: parse_uuid_csv("tag_id", params.tag_id.as_deref())?, limit, offset, sort: params.sort, }; - let (items, total) = repo::manga::list(&state.db, &q).await?; + let (items, total) = repo::manga::list_cards(&state.db, &q).await?; Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } async fn get_one( State(state): State, Path(id): Path, -) -> AppResult> { - Ok(Json(repo::manga::get(&state.db, id).await?)) +) -> AppResult> { + Ok(Json(repo::manga::get_detail(&state.db, id).await?)) } /// `POST /api/v1/mangas` is multipart/form-data. Parts: /// -/// - `metadata` (required): JSON body matching `NewManga`. +/// - `metadata` (required): JSON body matching `NewManga` — title, optional +/// status, optional authors (array of names), optional alt_titles +/// (array), optional description, optional genre_ids (array of UUIDs). /// - `cover` (optional): image bytes. MIME is sniffed from magic bytes /// (jpeg/png/webp/gif/avif); size capped at `upload.max_file_bytes`. /// -/// Anything else is ignored. +/// Anything else is ignored. Authors are upserted into the global +/// authors table (case-insensitive de-dup); genre_ids are validated +/// against the curated genres list. async fn create( State(state): State, CurrentUser(_user): CurrentUser, mut multipart: Multipart, -) -> AppResult<(StatusCode, Json)> { +) -> AppResult<(StatusCode, Json)> { let mut metadata: Option = None; let mut cover: Option = None; @@ -93,15 +143,36 @@ async fn create( details: json!({ "metadata": "required" }), })?; validate_new_manga(&metadata)?; + validate_genre_ids(&state, &metadata.genre_ids).await?; - // Transactional create. If the cover put or the cover_image_path - // UPDATE fails, the manga row is rolled back so a half-uploaded - // cover doesn't leave a manga without one stuck pointing at it. - // Bytes already written to storage on a rolled-back transaction - // become orphans on disk — a future reaper can sweep them; we - // prioritise DB consistency over storage tidiness here. + let status = metadata + .status + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(repo::manga::DEFAULT_STATUS) + .to_string(); + let alt_titles = normalize_alt_titles(metadata.alt_titles); + let authors = normalize_string_list(metadata.authors); + + // Transactional create. If the cover put or any relation write + // fails, the manga row and its relations are rolled back so a + // half-uploaded cover doesn't leave a manga without one stuck + // pointing at it. Bytes already written to storage on a rolled-back + // transaction become orphans on disk — a future reaper can sweep + // them; we prioritise DB consistency over storage tidiness here. let mut tx = state.db.begin().await?; - let mut manga = repo::manga::create(&mut *tx, metadata).await?; + let mut manga = repo::manga::create( + &mut *tx, + metadata.title.trim(), + &status, + metadata.description.as_deref(), + &alt_titles, + ) + .await?; + + let author_refs = repo::author::set_for_manga(&mut *tx, manga.id, &authors).await?; + repo::genre::set_for_manga(&mut *tx, manga.id, &metadata.genre_ids).await?; if let Some(img) = cover { let key = format!("mangas/{}/cover.{}", manga.id, img.ext); @@ -112,7 +183,135 @@ async fn create( tx.commit().await?; - Ok((StatusCode::CREATED, Json(manga))) + // Re-read so the genres list reflects the canonical sort order + // and any future trigger-driven defaults. + let detail = repo::manga::get_detail(&state.db, manga.id).await?; + let _ = author_refs; // returned for free via detail + Ok((StatusCode::CREATED, Json(detail))) +} + +async fn update( + State(state): State, + CurrentUser(_user): CurrentUser, + Path(id): Path, + Json(patch): Json, +) -> AppResult> { + // TODO(auth): until uploaders are tracked (Phase 5), any signed-in + // user can edit any manga. Restrict to uploader + admin once that + // column lands. + if !repo::manga::exists(&state.db, id).await? { + return Err(AppError::NotFound); + } + + if let Some(ref status) = patch.status { + let trimmed = status.trim(); + if !repo::manga::STATUSES.contains(&trimmed) { + return Err(AppError::ValidationFailed { + message: "invalid status".into(), + details: json!({ "status": format!("must be one of {:?}", repo::manga::STATUSES) }), + }); + } + } + if let Some(ref title) = patch.title { + if title.trim().is_empty() { + return Err(AppError::ValidationFailed { + message: "title cannot be blank".into(), + details: json!({ "title": "non-empty" }), + }); + } + } + if let Some(ref ids) = patch.genre_ids { + validate_genre_ids(&state, ids).await?; + } + + let alt_titles_owned: Option> = + patch.alt_titles.map(normalize_alt_titles); + let authors_owned: Option> = + patch.authors.map(normalize_string_list); + + 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 mut tx = state.db.begin().await?; + let _updated = repo::manga::update_basics( + &mut *tx, + id, + patch.title.as_deref().map(str::trim), + patch.status.as_deref().map(str::trim), + description_provided, + description_value, + alt_titles_owned.as_deref(), + ) + .await?; + if let Some(ref names) = authors_owned { + repo::author::set_for_manga(&mut *tx, id, names).await?; + } + if let Some(ref ids) = patch.genre_ids { + repo::genre::set_for_manga(&mut *tx, id, ids).await?; + } + tx.commit().await?; + + Ok(Json(repo::manga::get_detail(&state.db, id).await?)) +} + +#[derive(Debug, Deserialize)] +pub struct AttachTagBody { + pub name: String, +} + +async fn attach_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(body): Json, +) -> AppResult<(StatusCode, Json)> { + if !repo::manga::exists(&state.db, id).await? { + return Err(AppError::NotFound); + } + let outcome = repo::tag::attach_to_manga(&state.db, id, &body.name, user.id).await?; + let status = if outcome.created_attachment { + StatusCode::CREATED + } else { + StatusCode::OK + }; + // `added_by` reflects who currently owns the attachment — which is + // the first attacher, not the caller. Look it up so the response + // matches what subsequent list_for_manga reads will show. + let added_by = repo::tag::find_attacher(&state.db, id, outcome.tag.id) + .await? + .flatten(); + Ok(( + status, + Json(TagRef { + id: outcome.tag.id, + name: outcome.tag.name, + added_by, + }), + )) +} + +async fn detach_tag( + State(state): State, + CurrentUser(user): CurrentUser, + Path((manga_id, tag_id)): Path<(Uuid, Uuid)>, +) -> AppResult { + match repo::tag::find_attacher(&state.db, manga_id, tag_id).await? { + None => Err(AppError::NotFound), + Some(added_by) => { + // `added_by` is None if the attaching user was deleted; in + // that case nobody owns it, so we refuse rather than let + // any user clear orphan tags (an admin role can do that + // later). + if added_by != Some(user.id) { + return Err(AppError::Forbidden); + } + repo::tag::detach(&state.db, manga_id, tag_id).await?; + Ok(StatusCode::NO_CONTENT) + } + } } fn validate_new_manga(input: &NewManga) -> AppResult<()> { @@ -122,9 +321,75 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> { details: json!({ "title": "required" }), }); } + if let Some(ref status) = input.status { + let trimmed = status.trim(); + if !trimmed.is_empty() && !repo::manga::STATUSES.contains(&trimmed) { + return Err(AppError::ValidationFailed { + message: "invalid status".into(), + details: json!({ "status": format!("must be one of {:?}", repo::manga::STATUSES) }), + }); + } + } Ok(()) } +async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> { + if ids.is_empty() { + return Ok(()); + } + // Dedup before counting — Postgres' `id = ANY(...)` is set-based, + // so a request with `[g1, g1]` would match one row and trip the + // "unknown id" branch even though the only id is real. The DB-side + // insert path also dedupes via `ON CONFLICT DO NOTHING`, so we + // simply silently accept duplicates from the client. + let unique: std::collections::HashSet = ids.iter().copied().collect(); + let (matched,): (i64,) = + sqlx::query_as("SELECT count(*) FROM genres WHERE id = ANY($1)") + .bind(ids) + .fetch_one(&state.db) + .await?; + if (matched as usize) != unique.len() { + return Err(AppError::ValidationFailed { + message: "unknown genre id".into(), + details: json!({ "genre_ids": "all ids must reference an existing genre" }), + }); + } + Ok(()) +} + +fn normalize_alt_titles(raw: Vec) -> Vec { + let mut out = Vec::with_capacity(raw.len()); + for t in raw { + let trimmed = t.trim(); + if trimmed.is_empty() { + continue; + } + let s = trimmed.to_string(); + if !out.contains(&s) { + out.push(s); + } + } + out +} + +fn normalize_string_list(raw: Vec) -> Vec { + let mut out = Vec::with_capacity(raw.len()); + for s in raw { + let trimmed = s.trim(); + if trimmed.is_empty() { + continue; + } + // Case-insensitive de-dup so ["Miura", "miura"] becomes one entry. + if !out + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(trimmed)) + { + out.push(trimmed.to_string()); + } + } + out +} + fn parse_metadata_json(bytes: &[u8]) -> AppResult { serde_json::from_slice(bytes).map_err(|e| AppError::ValidationFailed { message: "metadata is not valid JSON".into(), diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index a0fc1b6..5474721 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -2,9 +2,11 @@ pub mod auth; pub mod bookmarks; pub mod chapters; pub mod files; +pub mod genres; pub mod health; pub mod mangas; pub mod pagination; +pub mod tags; use axum::Router; @@ -18,4 +20,6 @@ pub fn routes() -> Router { .merge(files::routes()) .merge(auth::routes()) .merge(bookmarks::routes()) + .merge(genres::routes()) + .merge(tags::routes()) } diff --git a/backend/src/api/tags.rs b/backend/src/api/tags.rs new file mode 100644 index 0000000..3c10b9c --- /dev/null +++ b/backend/src/api/tags.rs @@ -0,0 +1,37 @@ +use axum::extract::{Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; + +use crate::app::AppState; +use crate::domain::tag::Tag; +use crate::error::AppResult; +use crate::repo; + +pub fn routes() -> Router { + Router::new().route("/tags", get(list)) +} + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default)] + pub search: Option, + #[serde(default = "default_limit")] + pub limit: i64, +} + +fn default_limit() -> i64 { + 10 +} + +async fn list( + State(state): State, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 50); + let search = params.search.as_deref().and_then(|s| { + let t = s.trim(); + if t.is_empty() { None } else { Some(t) } + }); + Ok(Json(repo::tag::list(&state.db, search, limit).await?)) +} diff --git a/backend/src/domain/author.rs b/backend/src/domain/author.rs new file mode 100644 index 0000000..1b4b167 --- /dev/null +++ b/backend/src/domain/author.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Author { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, +} + +/// Slimmer shape embedded in manga responses — id + name is all a chip +/// needs to render and link to the author page. +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct AuthorRef { + pub id: Uuid, + pub name: String, +} + +/// Returned by `GET /authors/:id`. `manga_count` is denormalized from +/// `manga_authors` so the author page can show it without an extra +/// round-trip. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AuthorWithCount { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, + pub manga_count: i64, +} diff --git a/backend/src/domain/genre.rs b/backend/src/domain/genre.rs new file mode 100644 index 0000000..8ea4c47 --- /dev/null +++ b/backend/src/domain/genre.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Genre { + pub id: Uuid, + pub name: String, +} + +/// Same shape as `Genre` today, but kept distinct from the canonical +/// type so future fields on `Genre` (description, slug…) don't bloat +/// every manga payload. +pub type GenreRef = Genre; diff --git a/backend/src/domain/manga.rs b/backend/src/domain/manga.rs index 31c0a59..fb6c3b6 100644 --- a/backend/src/domain/manga.rs +++ b/backend/src/domain/manga.rs @@ -3,20 +3,152 @@ use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; +use super::author::AuthorRef; +use super::genre::GenreRef; +use super::tag::TagRef; + #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Manga { pub id: Uuid, pub title: String, - pub author: Option, + pub status: String, + pub alt_titles: Vec, pub description: Option, pub cover_image_path: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Clone, Deserialize)] +/// Shape returned by list endpoints. Cards show the title, authors and +/// genres; tags are intentionally elided here to keep the response +/// proportional to what a card actually renders. +#[derive(Debug, Clone, Serialize)] +pub struct MangaCard { + #[serde(flatten)] + pub manga: Manga, + pub authors: Vec, + pub genres: Vec, +} + +/// Shape returned by `GET /mangas/:id`. Adds user-added tags on top of +/// the card fields. +#[derive(Debug, Clone, Serialize)] +pub struct MangaDetail { + #[serde(flatten)] + pub manga: Manga, + pub authors: Vec, + pub genres: Vec, + pub tags: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] pub struct NewManga { pub title: String, - pub author: Option, + #[serde(default)] + pub status: Option, + /// Author display names. Looked up case-insensitively; created on + /// the fly when unseen. + #[serde(default)] + pub authors: Vec, + #[serde(default)] pub description: Option, + #[serde(default)] + pub alt_titles: Vec, + #[serde(default)] + pub genre_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MangaPatch { + pub title: Option, + pub status: Option, + /// Three-state: missing key leaves the description alone; explicit + /// `null` clears it; a string sets it. See `Patch` for details. + #[serde(default)] + pub description: Patch, + pub alt_titles: Option>, + /// When provided, replaces the manga's authors atomically. + pub authors: Option>, + /// When provided, replaces the manga's genres atomically. + pub genre_ids: Option>, +} + +/// Three-state container for nullable PATCH fields. +/// +/// `serde`'s default behaviour collapses both "field missing" and +/// "field is `null`" to `Option::None`, which means an `Option` +/// patch field can't distinguish "leave alone" from "set to NULL". +/// `Patch` carries that distinction by deserializing JSON `null` +/// into `Clear` and any value into `Set`; with `#[serde(default)]` on +/// the field, a missing key falls through to `Unchanged`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Patch { + /// Field absent from the request — leave the column untouched. + #[default] + Unchanged, + /// Field present and explicitly `null` — set the column to NULL. + Clear, + /// Field present with a value — set the column to that value. + Set(T), +} + +impl Patch { + /// Whether the request indicated this field should be written + /// (either to a new value or to NULL). + pub fn is_provided(&self) -> bool { + !matches!(self, Patch::Unchanged) + } + + /// The value to bind when writing, or `None` for `Unchanged`/`Clear`. + pub fn set_value(&self) -> Option<&T> { + match self { + Patch::Set(v) => Some(v), + _ => None, + } + } +} + +impl<'de, T> serde::Deserialize<'de> for Patch +where + T: serde::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Option::::deserialize(deserializer).map(|opt| match opt { + Some(v) => Patch::Set(v), + None => Patch::Clear, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[derive(Deserialize)] + struct Holder { + #[serde(default)] + desc: Patch, + } + + #[test] + fn missing_key_is_unchanged() { + let h: Holder = serde_json::from_value(json!({})).unwrap(); + assert_eq!(h.desc, Patch::Unchanged); + } + + #[test] + fn explicit_null_is_clear() { + let h: Holder = serde_json::from_value(json!({ "desc": null })).unwrap(); + assert_eq!(h.desc, Patch::Clear); + } + + #[test] + fn value_is_set() { + let h: Holder = serde_json::from_value(json!({ "desc": "x" })).unwrap(); + assert_eq!(h.desc, Patch::Set("x".into())); + } } diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index cb53180..5945d96 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -1,17 +1,23 @@ pub mod api_token; +pub mod author; pub mod bookmark; pub mod chapter; +pub mod genre; pub mod manga; pub mod page; pub mod session; +pub mod tag; pub mod user; pub mod user_preferences; pub use api_token::ApiToken; +pub use author::{Author, AuthorRef, AuthorWithCount}; pub use bookmark::{Bookmark, BookmarkSummary}; pub use chapter::Chapter; -pub use manga::Manga; +pub use genre::{Genre, GenreRef}; +pub use manga::{Manga, MangaCard, MangaDetail}; pub use page::Page; pub use session::Session; +pub use tag::{Tag, TagRef}; pub use user::User; pub use user_preferences::UserPreferences; diff --git a/backend/src/domain/tag.rs b/backend/src/domain/tag.rs new file mode 100644 index 0000000..66d6d35 --- /dev/null +++ b/backend/src/domain/tag.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Tag { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, +} + +/// Tag embedded on a manga payload, including the user who attached it +/// so the UI can render a remove button only to them. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct TagRef { + pub id: Uuid, + pub name: String, + pub added_by: Option, +} diff --git a/backend/src/repo/author.rs b/backend/src/repo/author.rs new file mode 100644 index 0000000..fa8e187 --- /dev/null +++ b/backend/src/repo/author.rs @@ -0,0 +1,205 @@ +//! Author persistence. Same plain-function style as the other repos. +//! +//! Names are stored with the casing of the first submission; lookups go +//! through the case-insensitive unique index on `lower(name)` so +//! "Kentaro Miura" and "kentaro miura" share one row. + +use std::collections::BTreeMap; + +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::domain::author::{Author, AuthorRef, AuthorWithCount}; +use crate::domain::manga::Manga; +use crate::error::{AppError, AppResult}; + +/// Insert-or-find by name. Returns the canonical row whether or not +/// this call created it. The `ON CONFLICT … DO UPDATE` no-op is what +/// makes `RETURNING` come back even on the existing-row path. +pub async fn upsert_by_name<'e, E: PgExecutor<'e>>( + executor: E, + name: &str, +) -> AppResult { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AppError::ValidationFailed { + message: "author name cannot be empty".into(), + details: serde_json::json!({ "authors": "non-empty names required" }), + }); + } + let row = sqlx::query_as::<_, Author>( + r#" + INSERT INTO authors (name) + VALUES ($1) + ON CONFLICT (lower(name)) DO UPDATE SET name = authors.name + RETURNING id, name, created_at + "#, + ) + .bind(trimmed) + .fetch_one(executor) + .await?; + Ok(row) +} + +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult { + sqlx::query_as::<_, Author>( + "SELECT id, name, created_at FROM authors WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} + +pub async fn find_with_count(pool: &PgPool, id: Uuid) -> AppResult { + sqlx::query_as::<_, AuthorWithCount>( + r#" + SELECT a.id, + a.name, + a.created_at, + (SELECT count(*) FROM manga_authors ma WHERE ma.author_id = a.id) AS manga_count + FROM authors a + WHERE a.id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} + +/// Autocomplete-friendly: substring + trigram match on `name`, ordered +/// most-similar first when a search term is present. +pub async fn list( + pool: &PgPool, + search: Option<&str>, + limit: i64, + offset: i64, +) -> AppResult> { + let rows = sqlx::query_as::<_, Author>( + r#" + SELECT id, name, created_at + FROM authors + WHERE $1::text IS NULL + OR name ILIKE '%' || $1 || '%' + OR name % $1 + ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC, + lower(name) ASC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(search) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Atomically replace the set of authors on a manga. Caller passes a +/// `&mut PgConnection` (`&mut *tx` works) so the delete+upserts run in +/// one transaction with whatever called us. +pub async fn set_for_manga( + conn: &mut PgConnection, + manga_id: Uuid, + names: &[String], +) -> AppResult> { + sqlx::query("DELETE FROM manga_authors WHERE manga_id = $1") + .bind(manga_id) + .execute(&mut *conn) + .await?; + + let mut refs = Vec::with_capacity(names.len()); + for (position, name) in names.iter().enumerate() { + let author = upsert_by_name(&mut *conn, name).await?; + sqlx::query( + "INSERT INTO manga_authors (manga_id, author_id, position) VALUES ($1, $2, $3) + ON CONFLICT (manga_id, author_id) DO NOTHING", + ) + .bind(manga_id) + .bind(author.id) + .bind(position as i32) + .execute(&mut *conn) + .await?; + refs.push(AuthorRef { id: author.id, name: author.name }); + } + Ok(refs) +} + +pub async fn list_for_manga(pool: &PgPool, manga_id: Uuid) -> AppResult> { + let rows = sqlx::query_as::<_, AuthorRef>( + r#" + SELECT a.id, a.name + FROM manga_authors ma + JOIN authors a ON a.id = ma.author_id + WHERE ma.manga_id = $1 + ORDER BY ma.position, lower(a.name) + "#, + ) + .bind(manga_id) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Batch-load authors for a list of manga ids and group by manga. Used +/// by the list endpoint to avoid N+1 round-trips. +pub async fn load_for_mangas( + pool: &PgPool, + manga_ids: &[Uuid], +) -> AppResult>> { + if manga_ids.is_empty() { + return Ok(BTreeMap::new()); + } + let rows: Vec<(Uuid, Uuid, String)> = sqlx::query_as( + r#" + SELECT ma.manga_id, a.id, a.name + FROM manga_authors ma + JOIN authors a ON a.id = ma.author_id + WHERE ma.manga_id = ANY($1) + ORDER BY ma.manga_id, ma.position, lower(a.name) + "#, + ) + .bind(manga_ids) + .fetch_all(pool) + .await?; + let mut grouped: BTreeMap> = BTreeMap::new(); + for (manga_id, author_id, name) in rows { + grouped + .entry(manga_id) + .or_default() + .push(AuthorRef { id: author_id, name }); + } + Ok(grouped) +} + +pub async fn list_mangas_for_author( + pool: &PgPool, + author_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let rows = sqlx::query_as::<_, Manga>( + r#" + SELECT m.id, m.title, m.status, m.alt_titles, m.description, + m.cover_image_path, m.created_at, m.updated_at + FROM manga_authors ma + JOIN mangas m ON m.id = ma.manga_id + WHERE ma.author_id = $1 + ORDER BY m.created_at DESC, m.id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(author_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = sqlx::query_as( + "SELECT count(*) FROM manga_authors WHERE author_id = $1", + ) + .bind(author_id) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} diff --git a/backend/src/repo/genre.rs b/backend/src/repo/genre.rs new file mode 100644 index 0000000..95ee11b --- /dev/null +++ b/backend/src/repo/genre.rs @@ -0,0 +1,88 @@ +use std::collections::BTreeMap; + +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use crate::domain::genre::{Genre, GenreRef}; +use crate::error::AppResult; + +pub async fn list_all(pool: &PgPool) -> AppResult> { + let rows = sqlx::query_as::<_, Genre>( + "SELECT id, name FROM genres ORDER BY lower(name)", + ) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn list_for_manga(pool: &PgPool, manga_id: Uuid) -> AppResult> { + let rows = sqlx::query_as::<_, GenreRef>( + r#" + SELECT g.id, g.name + FROM manga_genres mg + JOIN genres g ON g.id = mg.genre_id + WHERE mg.manga_id = $1 + ORDER BY lower(g.name) + "#, + ) + .bind(manga_id) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn load_for_mangas( + pool: &PgPool, + manga_ids: &[Uuid], +) -> AppResult>> { + if manga_ids.is_empty() { + return Ok(BTreeMap::new()); + } + let rows: Vec<(Uuid, Uuid, String)> = sqlx::query_as( + r#" + SELECT mg.manga_id, g.id, g.name + FROM manga_genres mg + JOIN genres g ON g.id = mg.genre_id + WHERE mg.manga_id = ANY($1) + ORDER BY mg.manga_id, lower(g.name) + "#, + ) + .bind(manga_ids) + .fetch_all(pool) + .await?; + let mut grouped: BTreeMap> = BTreeMap::new(); + for (manga_id, id, name) in rows { + grouped.entry(manga_id).or_default().push(GenreRef { id, name }); + } + Ok(grouped) +} + +/// Replace the manga's genres. Unknown ids are silently ignored: the +/// FK constraint would reject them, so we filter upstream rather than +/// surface a 500 here. (The API layer validates the set against +/// `list_all` first.) +pub async fn set_for_manga( + conn: &mut PgConnection, + manga_id: Uuid, + genre_ids: &[Uuid], +) -> AppResult<()> { + sqlx::query("DELETE FROM manga_genres WHERE manga_id = $1") + .bind(manga_id) + .execute(&mut *conn) + .await?; + if genre_ids.is_empty() { + return Ok(()); + } + sqlx::query( + r#" + INSERT INTO manga_genres (manga_id, genre_id) + SELECT $1, unnest($2::uuid[]) + ON CONFLICT DO NOTHING + "#, + ) + .bind(manga_id) + .bind(genre_ids) + .execute(&mut *conn) + .await?; + Ok(()) +} diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 32f8e7d..50a8c12 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -6,11 +6,17 @@ //! a trait + impl if a second backend ever becomes necessary. use serde::Deserialize; -use sqlx::{PgExecutor, PgPool}; +use sqlx::{PgConnection, PgExecutor, PgPool}; use uuid::Uuid; -use crate::domain::manga::{Manga, NewManga}; +use crate::domain::manga::{Manga, MangaCard, MangaDetail}; use crate::error::{AppError, AppResult}; +use crate::repo; + +/// Status values mirror the CHECK constraint in 0009. Centralized so +/// the API layer can validate uploads against the same vocabulary. +pub const STATUSES: &[&str] = &["ongoing", "completed"]; +pub const DEFAULT_STATUS: &str = "ongoing"; #[derive(Debug, Clone, Copy, Default, Deserialize)] #[serde(rename_all = "snake_case")] @@ -22,28 +28,65 @@ pub enum ListSort { Title, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ListQuery { pub search: Option, + pub status: Option, + pub author_ids: Vec, + pub genre_ids: Vec, + pub tag_ids: Vec, pub limit: i64, pub offset: i64, pub sort: ListSort, } -impl Default for ListQuery { - fn default() -> Self { - Self { - search: None, - limit: 50, - offset: 0, - sort: ListSort::Recent, - } - } -} +const SELECT_COLS: &str = + "id, title, status, alt_titles, description, cover_image_path, created_at, updated_at"; + +/// Shared WHERE used by both the rows and the count queries. Filters +/// are AND across facets: every supplied author_id (or genre, or tag) +/// must be attached to the manga for it to match. Empty arrays mean +/// "no filter" because `NOT EXISTS` over an empty unnest is vacuously +/// true. +const FILTER_WHERE: &str = r#" + ($1::text IS NULL + OR title ILIKE '%' || $1 || '%' + OR title % $1 + OR EXISTS ( + SELECT 1 FROM manga_authors ma + JOIN authors a ON a.id = ma.author_id + WHERE ma.manga_id = mangas.id + AND (a.name ILIKE '%' || $1 || '%' OR a.name % $1) + ) + ) + AND ($2::text IS NULL OR status = $2) + AND NOT EXISTS ( + SELECT 1 FROM unnest($3::uuid[]) AS req(id) + WHERE NOT EXISTS ( + SELECT 1 FROM manga_authors ma + WHERE ma.manga_id = mangas.id AND ma.author_id = req.id + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM unnest($4::uuid[]) AS req(id) + WHERE NOT EXISTS ( + SELECT 1 FROM manga_genres mg + WHERE mg.manga_id = mangas.id AND mg.genre_id = req.id + ) + ) + AND NOT EXISTS ( + SELECT 1 FROM unnest($5::uuid[]) AS req(id) + WHERE NOT EXISTS ( + SELECT 1 FROM manga_tags mt + WHERE mt.manga_id = mangas.id AND mt.tag_id = req.id + ) + ) +"#; /// Returns the page of mangas matching `query` plus the unfiltered total -/// count for the same filter. The trigram GIN indexes (see 0005_search.sql) -/// keep both queries cheap as the library grows. +/// count for the same filter. The trigram GIN indexes (see 0005_search.sql +/// and 0009_manga_metadata.sql) keep both queries cheap as the library +/// grows. pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, i64)> { // `order_by` is interpolated from a hard-coded enum, never from request // input, so this is not a SQL injection seam. @@ -53,80 +96,152 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, i6 }; let search = query.search.as_deref(); + let status = query.status.as_deref(); let list_sql = format!( r#" - SELECT id, title, author, description, cover_image_path, created_at, updated_at + SELECT {SELECT_COLS} FROM mangas - WHERE $1::text IS NULL - OR title ILIKE '%' || $1 || '%' - OR COALESCE(author, '') ILIKE '%' || $1 || '%' - OR title % $1 - OR (author IS NOT NULL AND author % $1) + WHERE {FILTER_WHERE} ORDER BY {order_by} - LIMIT $2 OFFSET $3 + LIMIT $6 OFFSET $7 "# ); let rows = sqlx::query_as::<_, Manga>(&list_sql) .bind(search) + .bind(status) + .bind(&query.author_ids) + .bind(&query.genre_ids) + .bind(&query.tag_ids) .bind(query.limit) .bind(query.offset) .fetch_all(pool) .await?; - let count_sql = r#" + let count_sql = format!( + r#" SELECT count(*) FROM mangas - WHERE $1::text IS NULL - OR title ILIKE '%' || $1 || '%' - OR COALESCE(author, '') ILIKE '%' || $1 || '%' - OR title % $1 - OR (author IS NOT NULL AND author % $1) - "#; - let (total,): (i64,) = sqlx::query_as(count_sql) + WHERE {FILTER_WHERE} + "# + ); + let (total,): (i64,) = sqlx::query_as(&count_sql) .bind(search) + .bind(status) + .bind(&query.author_ids) + .bind(&query.genre_ids) + .bind(&query.tag_ids) .fetch_one(pool) .await?; Ok((rows, total)) } +/// Same filter as `list`, but wraps each row with its authors + genres +/// in a single batched round-trip. Tags are intentionally not loaded +/// here — see `MangaCard` in the domain layer. +pub async fn list_cards( + pool: &PgPool, + query: &ListQuery, +) -> AppResult<(Vec, i64)> { + let (rows, total) = list(pool, query).await?; + let ids: Vec = rows.iter().map(|m| m.id).collect(); + let mut authors = repo::author::load_for_mangas(pool, &ids).await?; + let mut genres = repo::genre::load_for_mangas(pool, &ids).await?; + let cards = rows + .into_iter() + .map(|manga| MangaCard { + authors: authors.remove(&manga.id).unwrap_or_default(), + genres: genres.remove(&manga.id).unwrap_or_default(), + manga, + }) + .collect(); + Ok((cards, total)) +} + pub async fn get(pool: &PgPool, id: Uuid) -> AppResult { - sqlx::query_as::<_, Manga>( - r#" - SELECT id, title, author, description, cover_image_path, created_at, updated_at - FROM mangas - WHERE id = $1 - "#, - ) + sqlx::query_as::<_, Manga>(&format!( + "SELECT {SELECT_COLS} FROM mangas WHERE id = $1" + )) .bind(id) .fetch_optional(pool) .await? .ok_or(AppError::NotFound) } -/// Accepts any `PgExecutor` so callers can pass `&PgPool` for simple -/// inserts or `&mut *tx` to run inside a transaction. Same applies to -/// `set_cover_image_path` below. +pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult { + let manga = get(pool, id).await?; + let authors = repo::author::list_for_manga(pool, id).await?; + let genres = repo::genre::list_for_manga(pool, id).await?; + let tags = repo::tag::list_for_manga(pool, id).await?; + Ok(MangaDetail { manga, authors, genres, tags }) +} + +/// Insert just the manga row. Relations (authors, genres) are written +/// by the caller via `repo::author::set_for_manga` etc. in the same +/// transaction. `status` is taken as a validated string — the handler +/// is responsible for defaulting/validating it. pub async fn create<'e, E: PgExecutor<'e>>( executor: E, - input: NewManga, + title: &str, + status: &str, + description: Option<&str>, + alt_titles: &[String], ) -> AppResult { - let row = sqlx::query_as::<_, Manga>( + let row = sqlx::query_as::<_, Manga>(&format!( r#" - INSERT INTO mangas (title, author, description) - VALUES ($1, $2, $3) - RETURNING id, title, author, description, cover_image_path, created_at, updated_at - "#, - ) - .bind(&input.title) - .bind(&input.author) - .bind(&input.description) + INSERT INTO mangas (title, status, description, alt_titles) + VALUES ($1, $2, $3, $4) + RETURNING {SELECT_COLS} + "# + )) + .bind(title) + .bind(status) + .bind(description) + .bind(alt_titles) .fetch_one(executor) .await?; Ok(row) } +/// Patch the inline columns. Each `Option` argument leaves the field +/// untouched when `None`. `description` uses two binds — a `provided` +/// boolean and the new value — so callers can distinguish "leave +/// alone" from "set to NULL" (both look the same in a plain +/// `Option<&str>` because serde collapses missing and explicit-null). +pub async fn update_basics( + conn: &mut PgConnection, + id: Uuid, + title: Option<&str>, + status: Option<&str>, + description_provided: bool, + description: Option<&str>, + alt_titles: Option<&[String]>, +) -> AppResult { + let row = sqlx::query_as::<_, Manga>(&format!( + r#" + UPDATE mangas + SET title = COALESCE($2, title), + status = COALESCE($3, status), + description = CASE WHEN $4::boolean THEN $5 ELSE description END, + alt_titles = COALESCE($6, alt_titles), + updated_at = now() + WHERE id = $1 + RETURNING {SELECT_COLS} + "# + )) + .bind(id) + .bind(title) + .bind(status) + .bind(description_provided) + .bind(description) + .bind(alt_titles) + .fetch_optional(&mut *conn) + .await? + .ok_or(AppError::NotFound)?; + Ok(row) +} + pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>( executor: E, id: Uuid, @@ -139,3 +254,12 @@ pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>( .await?; Ok(()) } + +pub async fn exists(pool: &PgPool, id: Uuid) -> AppResult { + let (exists,): (bool,) = + sqlx::query_as("SELECT EXISTS(SELECT 1 FROM mangas WHERE id = $1)") + .bind(id) + .fetch_one(pool) + .await?; + Ok(exists) +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 95cad9d..9f25ee5 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -1,8 +1,11 @@ pub mod api_token; +pub mod author; pub mod bookmark; pub mod chapter; +pub mod genre; pub mod manga; pub mod page; pub mod session; +pub mod tag; pub mod user; pub mod user_preferences; diff --git a/backend/src/repo/tag.rs b/backend/src/repo/tag.rs new file mode 100644 index 0000000..2feb616 --- /dev/null +++ b/backend/src/repo/tag.rs @@ -0,0 +1,149 @@ +use sqlx::{PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::domain::tag::{Tag, TagRef}; +use crate::error::{AppError, AppResult}; + +/// True when this manga-tag attachment is new (so the API can return +/// 201 vs 200), plus the canonical tag row. +pub struct AttachOutcome { + pub tag: Tag, + pub created_attachment: bool, +} + +pub async fn upsert_by_name<'e, E: PgExecutor<'e>>( + executor: E, + name: &str, +) -> AppResult { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AppError::ValidationFailed { + message: "tag name cannot be empty".into(), + details: serde_json::json!({ "name": "required" }), + }); + } + if trimmed.chars().count() > 64 { + return Err(AppError::ValidationFailed { + message: "tag name too long".into(), + details: serde_json::json!({ "name": "max 64 characters" }), + }); + } + let row = sqlx::query_as::<_, Tag>( + r#" + INSERT INTO tags (name) + VALUES ($1) + ON CONFLICT (lower(name)) DO UPDATE SET name = tags.name + RETURNING id, name, created_at + "#, + ) + .bind(trimmed) + .fetch_one(executor) + .await?; + Ok(row) +} + +/// Attach a tag to a manga, idempotently. Returns whether a new +/// attachment row was created so the API can pick 201 vs 200. +pub async fn attach_to_manga( + pool: &PgPool, + manga_id: Uuid, + name: &str, + added_by: Uuid, +) -> AppResult { + let mut tx = pool.begin().await?; + let tag = upsert_by_name(&mut *tx, name).await?; + let rows_affected = sqlx::query( + r#" + INSERT INTO manga_tags (manga_id, tag_id, added_by) + VALUES ($1, $2, $3) + ON CONFLICT (manga_id, tag_id) DO NOTHING + "#, + ) + .bind(manga_id) + .bind(tag.id) + .bind(added_by) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + Ok(AttachOutcome { tag, created_attachment: rows_affected > 0 }) +} + +/// Look up who attached a given (manga, tag) pair, if any. Returns +/// `None` if the attachment doesn't exist. The outer `AppResult` only +/// surfaces DB errors. +pub async fn find_attacher( + pool: &PgPool, + manga_id: Uuid, + tag_id: Uuid, +) -> AppResult>> { + let row: Option<(Option,)> = sqlx::query_as( + "SELECT added_by FROM manga_tags WHERE manga_id = $1 AND tag_id = $2", + ) + .bind(manga_id) + .bind(tag_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(added_by,)| added_by)) +} + +pub async fn detach(pool: &PgPool, manga_id: Uuid, tag_id: Uuid) -> AppResult<()> { + sqlx::query("DELETE FROM manga_tags WHERE manga_id = $1 AND tag_id = $2") + .bind(manga_id) + .bind(tag_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_for_manga(pool: &PgPool, manga_id: Uuid) -> AppResult> { + let rows = sqlx::query_as::<_, TagRef>( + r#" + SELECT t.id, t.name, mt.added_by + FROM manga_tags mt + JOIN tags t ON t.id = mt.tag_id + WHERE mt.manga_id = $1 + ORDER BY lower(t.name) + "#, + ) + .bind(manga_id) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Tag autocomplete. Substring + trigram, no `added_by` (tags are +/// global), capped at the caller's limit. +pub async fn list( + pool: &PgPool, + search: Option<&str>, + limit: i64, +) -> AppResult> { + let rows = sqlx::query_as::<_, Tag>( + r#" + SELECT id, name, created_at + FROM tags + WHERE $1::text IS NULL + OR name ILIKE '%' || $1 || '%' + OR name % $1 + ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC, + lower(name) ASC + LIMIT $2 + "#, + ) + .bind(search) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult { + sqlx::query_as::<_, Tag>( + "SELECT id, name, created_at FROM tags WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} diff --git a/backend/tests/api_genres.rs b/backend/tests/api_genres.rs new file mode 100644 index 0000000..d34cca4 --- /dev/null +++ b/backend/tests/api_genres.rs @@ -0,0 +1,35 @@ +mod common; + +use axum::http::StatusCode; +use sqlx::PgPool; +use tower::ServiceExt; + +#[sqlx::test(migrations = "./migrations")] +async fn lists_seeded_genres(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/genres")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let items = body.as_array().expect("genres returned as a flat array"); + let names: Vec<&str> = items + .iter() + .map(|g| g["name"].as_str().unwrap()) + .collect(); + // The migration seeds a curated vocabulary; spot-check a few + // common ones so accidental removals fail loudly. + for expected in ["Action", "Comedy", "Romance", "Sci-Fi"] { + assert!( + names.contains(&expected), + "expected seeded genre {expected:?} in {names:?}" + ); + } + // Every genre must carry an id so the create/patch endpoints have + // something to reference. + for g in items { + assert!(g["id"].as_str().is_some()); + } +} diff --git a/backend/tests/api_mangas.rs b/backend/tests/api_mangas.rs index c369c77..87e57ec 100644 --- a/backend/tests/api_mangas.rs +++ b/backend/tests/api_mangas.rs @@ -158,7 +158,11 @@ async fn create_then_list_roundtrip(pool: PgPool) { "/api/v1/mangas", MultipartBuilder::new().add_json( "metadata", - json!({ "title": "Berserk", "author": "Kentaro Miura", "description": null }), + json!({ + "title": "Berserk", + "authors": ["Kentaro Miura"], + "description": null, + }), ), &cookie, )) @@ -167,14 +171,23 @@ async fn create_then_list_roundtrip(pool: PgPool) { assert_eq!(created.status(), StatusCode::CREATED); let body = common::body_json(created).await; assert_eq!(body["title"], "Berserk"); - assert_eq!(body["author"], "Kentaro Miura"); + let authors = body["authors"].as_array().unwrap(); + assert_eq!(authors.len(), 1); + assert_eq!(authors[0]["name"], "Kentaro Miura"); assert!(body["id"].as_str().is_some()); + // Status defaults to ongoing; tags and genres start empty. + assert_eq!(body["status"], "ongoing"); + assert_eq!(body["genres"], json!([])); + assert_eq!(body["tags"], json!([])); let listed = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap(); let listed_body = common::body_json(listed).await; let items = listed_body["items"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["title"], "Berserk"); + // List endpoint returns the card shape: authors + genres included. + let authors = items[0]["authors"].as_array().unwrap(); + assert_eq!(authors[0]["name"], "Kentaro Miura"); } #[sqlx::test(migrations = "./migrations")] @@ -193,13 +206,15 @@ async fn search_filters_by_title_and_author(pool: PgPool) { .oneshot(common::post_multipart_with_cookie( "/api/v1/mangas", MultipartBuilder::new() - .add_json("metadata", json!({ "title": title, "author": author })), + .add_json("metadata", json!({ "title": title, "authors": [author] })), &cookie, )) .await .unwrap(); } + // Searching by author name still works — the list query joins + // authors so 'miura' resolves through the manga_authors table. let resp = h .app .clone() diff --git a/backend/tests/api_mangas_metadata.rs b/backend/tests/api_mangas_metadata.rs new file mode 100644 index 0000000..2797d34 --- /dev/null +++ b/backend/tests/api_mangas_metadata.rs @@ -0,0 +1,568 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::{json, Value}; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +use common::MultipartBuilder; + +async fn genre_id_named(app: &axum::Router, name: &str) -> String { + let resp = app + .clone() + .oneshot(common::get("/api/v1/genres")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + body.as_array() + .unwrap() + .iter() + .find(|g| g["name"].as_str() == Some(name)) + .and_then(|g| g["id"].as_str().map(str::to_string)) + .unwrap_or_else(|| panic!("expected seeded genre {name}")) +} + +async fn create_manga( + app: &axum::Router, + cookie: &str, + metadata: Value, +) -> Value { + let resp = app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json("metadata", metadata), + cookie, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "create_manga failed: {:?}", + resp.status() + ); + common::body_json(resp).await +} + +fn id_of(body: &Value) -> Uuid { + Uuid::parse_str(body["id"].as_str().unwrap()).unwrap() +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_returns_enriched_detail_with_defaults(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let body = create_manga(&h.app, &cookie, json!({ "title": "Solo Manga" })).await; + assert_eq!(body["title"], "Solo Manga"); + assert_eq!(body["status"], "ongoing"); + assert_eq!(body["alt_titles"], json!([])); + assert_eq!(body["authors"], json!([])); + assert_eq!(body["genres"], json!([])); + assert_eq!(body["tags"], json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_with_full_metadata_roundtrips(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let action_id = genre_id_named(&h.app, "Action").await; + let fantasy_id = genre_id_named(&h.app, "Fantasy").await; + + let body = create_manga( + &h.app, + &cookie, + json!({ + "title": "Berserk", + "status": "completed", + "authors": ["Kentaro Miura", "Studio Gaga"], + "alt_titles": ["ベルセルク"], + "genre_ids": [action_id, fantasy_id], + "description": "Guts wields a big sword." + }), + ) + .await; + + assert_eq!(body["status"], "completed"); + assert_eq!(body["alt_titles"], json!(["ベルセルク"])); + let authors: Vec<&str> = body["authors"] + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!(authors, vec!["Kentaro Miura", "Studio Gaga"]); + let genres: Vec<&str> = body["genres"] + .as_array() + .unwrap() + .iter() + .map(|g| g["name"].as_str().unwrap()) + .collect(); + assert_eq!(genres, vec!["Action", "Fantasy"]); + + // GET /mangas/:id returns the same shape. + let id = id_of(&body); + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/mangas/{id}"))) + .await + .unwrap(); + let detail = common::body_json(resp).await; + assert_eq!(detail["status"], "completed"); + assert_eq!(detail["authors"].as_array().unwrap().len(), 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn invalid_status_rejected_with_422(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let resp = h + .app + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new() + .add_json("metadata", json!({ "title": "Foo", "status": "hiatus" })), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "validation_failed"); + assert!(body["error"]["details"]["status"].is_string()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn unknown_genre_id_rejected_with_422(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let unknown = Uuid::new_v4(); + + let resp = h + .app + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json( + "metadata", + json!({ "title": "Foo", "genre_ids": [unknown.to_string()] }), + ), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "validation_failed"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn author_dedups_case_insensitively(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let a = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk", "authors": ["Kentaro Miura"] }), + ) + .await; + let b = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk Prelude", "authors": ["kentaro miura"] }), + ) + .await; + + // Both mangas resolve the same author id even though the casing differed. + let id_a = a["authors"][0]["id"].as_str().unwrap(); + let id_b = b["authors"][0]["id"].as_str().unwrap(); + assert_eq!(id_a, id_b); +} + +#[sqlx::test(migrations = "./migrations")] +async fn filter_by_status_returns_only_matches(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let _ongoing = create_manga(&h.app, &cookie, json!({ "title": "Ongoing One" })).await; + let _done = create_manga( + &h.app, + &cookie, + json!({ "title": "Wrapped Up", "status": "completed" }), + ) + .await; + + let resp = h + .app + .oneshot(common::get("/api/v1/mangas?status=completed")) + .await + .unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + assert_eq!(titles, vec!["Wrapped Up"]); + assert_eq!(body["page"]["total"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn filter_by_multiple_genres_is_and(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let action = genre_id_named(&h.app, "Action").await; + let fantasy = genre_id_named(&h.app, "Fantasy").await; + let comedy = genre_id_named(&h.app, "Comedy").await; + + let _both = create_manga( + &h.app, + &cookie, + json!({ "title": "Action+Fantasy", "genre_ids": [action.clone(), fantasy.clone()] }), + ) + .await; + let _action_only = create_manga( + &h.app, + &cookie, + json!({ "title": "Action Only", "genre_ids": [action.clone()] }), + ) + .await; + let _other = create_manga( + &h.app, + &cookie, + json!({ "title": "Comedy Only", "genre_ids": [comedy] }), + ) + .await; + + let url = format!("/api/v1/mangas?genre_id={action},{fantasy}"); + let resp = h.app.oneshot(common::get(&url)).await.unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + // Only the manga tagged with BOTH genres matches — pure AND. + assert_eq!(titles, vec!["Action+Fantasy"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn filter_by_author_id_matches_only_works_by_that_author(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let miura_manga = create_manga( + &h.app, + &cookie, + json!({ "title": "Berserk", "authors": ["Kentaro Miura"] }), + ) + .await; + let miura_id = miura_manga["authors"][0]["id"].as_str().unwrap(); + let _oda = create_manga( + &h.app, + &cookie, + json!({ "title": "One Piece", "authors": ["Eiichiro Oda"] }), + ) + .await; + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/mangas?author_id={miura_id}"))) + .await + .unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + assert_eq!(titles, vec!["Berserk"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_updates_status_authors_and_genres(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let drama = genre_id_named(&h.app, "Drama").await; + + let created = create_manga( + &h.app, + &cookie, + json!({ "title": "WIP", "authors": ["Old Name"] }), + ) + .await; + let id = id_of(&created); + + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/mangas/{id}"), + json!({ + "status": "completed", + "authors": ["Old Name", "New Coauthor"], + "genre_ids": [drama] + }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["status"], "completed"); + let names: Vec<&str> = body["authors"] + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Old Name", "New Coauthor"]); + assert_eq!(body["genres"][0]["name"], "Drama"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_404_on_unknown_id(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/mangas/{}", Uuid::new_v4()), + json!({ "status": "completed" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn filter_by_unknown_uuid_returns_empty_not_error(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let _ = create_manga(&h.app, &cookie, json!({ "title": "Anything" })).await; + + let unknown = Uuid::new_v4(); + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/mangas?genre_id={unknown}"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["items"], json!([])); + assert_eq!(body["page"]["total"], 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn filter_combining_status_author_genre_and_tag_is_and(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let action = genre_id_named(&h.app, "Action").await; + + // The "winner" matches every facet; the other rows each miss at + // least one so the combined filter must reject them. + let winner = create_manga( + &h.app, + &cookie, + json!({ + "title": "Winner", + "status": "completed", + "authors": ["Solo Author"], + "genre_ids": [action.clone()], + }), + ) + .await; + let solo_author_id = winner["authors"][0]["id"].as_str().unwrap().to_string(); + let winner_id = id_of(&winner); + + let _missing_status = create_manga( + &h.app, + &cookie, + json!({ + "title": "Wrong Status", + "authors": ["Solo Author"], + "genre_ids": [action.clone()], + }), + ) + .await; + let _missing_author = create_manga( + &h.app, + &cookie, + json!({ + "title": "Wrong Author", + "status": "completed", + "authors": ["Other"], + "genre_ids": [action.clone()], + }), + ) + .await; + let _missing_genre = create_manga( + &h.app, + &cookie, + json!({ + "title": "Missing Genre", + "status": "completed", + "authors": ["Solo Author"], + }), + ) + .await; + + // Attach a tag to the winner so we can hit all four facets at once. + let tag_attach = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{winner_id}/tags"), + json!({ "name": "Pinned" }), + &cookie, + )) + .await + .unwrap(); + let tag_id = common::body_json(tag_attach).await["id"].as_str().unwrap().to_string(); + + let url = format!( + "/api/v1/mangas?status=completed&author_id={solo_author_id}&genre_id={action}&tag_id={tag_id}" + ); + let resp = h.app.oneshot(common::get(&url)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + assert_eq!(titles, vec!["Winner"]); + assert_eq!(body["page"]["total"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn duplicate_genre_ids_accepted_not_treated_as_unknown(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let action = genre_id_named(&h.app, "Action").await; + + let body = create_manga( + &h.app, + &cookie, + json!({ + "title": "Dup Genres", + "genre_ids": [action.clone(), action] + }), + ) + .await; + // The repeated id resolves to a single attachment (the join table + // de-dupes via the composite PK + `ON CONFLICT DO NOTHING`), so + // the response carries one genre, not two. + let names: Vec<&str> = body["genres"] + .as_array() + .unwrap() + .iter() + .map(|g| g["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["Action"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_explicit_null_description_clears_it(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let created = create_manga( + &h.app, + &cookie, + json!({ "title": "With desc", "description": "Original" }), + ) + .await; + let id = id_of(&created); + assert_eq!(created["description"], "Original"); + + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/mangas/{id}"), + json!({ "description": null }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert!(body["description"].is_null(), "expected description cleared"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_omitting_description_leaves_it_alone(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let created = create_manga( + &h.app, + &cookie, + json!({ "title": "With desc", "description": "Keep me" }), + ) + .await; + let id = id_of(&created); + + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/mangas/{id}"), + json!({ "status": "completed" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["description"], "Keep me"); + assert_eq!(body["status"], "completed"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_empty_alt_titles_clears_them(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let created = create_manga( + &h.app, + &cookie, + json!({ "title": "Has alts", "alt_titles": ["a", "b"] }), + ) + .await; + let id = id_of(&created); + assert_eq!(created["alt_titles"], json!(["a", "b"])); + + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/mangas/{id}"), + json!({ "alt_titles": [] }), + &cookie, + )) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["alt_titles"], json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_requires_authentication(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let created = create_manga(&h.app, &cookie, json!({ "title": "Auth Check" })).await; + let id = id_of(&created); + let resp = h + .app + .oneshot(common::patch_json( + &format!("/api/v1/mangas/{id}"), + json!({ "status": "completed" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/backend/tests/api_tags.rs b/backend/tests/api_tags.rs new file mode 100644 index 0000000..1ae77b3 --- /dev/null +++ b/backend/tests/api_tags.rs @@ -0,0 +1,260 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +#[sqlx::test(migrations = "./migrations")] +async fn attach_creates_tag_and_links_to_manga(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags"), + json!({ "name": "Dark Fantasy" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = common::body_json(resp).await; + assert_eq!(body["name"], "Dark Fantasy"); + assert!(body["id"].as_str().is_some()); + assert!(body["added_by"].as_str().is_some()); + + // The tag is now visible on the manga detail response. + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/mangas/{manga_id}"))) + .await + .unwrap(); + let detail = common::body_json(resp).await; + let tags = detail["tags"].as_array().unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0]["name"], "Dark Fantasy"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn reattach_same_tag_is_idempotent_and_returns_200(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let make = || { + common::post_json_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags"), + json!({ "name": "Gritty" }), + &cookie, + ) + }; + let first = h.app.clone().oneshot(make()).await.unwrap(); + assert_eq!(first.status(), StatusCode::CREATED); + let second = h.app.oneshot(make()).await.unwrap(); + assert_eq!(second.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn tag_names_dedup_case_insensitively(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let first = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags"), + json!({ "name": "Seinen" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::CREATED); + let first_body = common::body_json(first).await; + let first_id = first_body["id"].as_str().unwrap().to_string(); + + // Different casing on a second manga should resolve to the same tag id. + let other_manga = common::seed_manga_via_api(&h.app, &cookie, "Other").await; + let second = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{other_manga}/tags"), + json!({ "name": "seinen" }), + &cookie, + )) + .await + .unwrap(); + let second_body = common::body_json(second).await; + assert_eq!(second_body["id"].as_str().unwrap(), first_id); +} + +#[sqlx::test(migrations = "./migrations")] +async fn detach_only_by_attacher(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie_a) = common::register_user(&h.app).await; + let (_, cookie_b) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie_a, "Berserk").await; + + let attach = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags"), + json!({ "name": "Classic" }), + &cookie_a, + )) + .await + .unwrap(); + let attach_body = common::body_json(attach).await; + let tag_id = attach_body["id"].as_str().unwrap(); + + // User B cannot remove user A's tag attachment. + let resp = h + .app + .clone() + .oneshot(common::delete_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags/{tag_id}"), + &cookie_b, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // User A can. + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags/{tag_id}"), + &cookie_a, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +} + +#[sqlx::test(migrations = "./migrations")] +async fn detach_404_when_not_attached(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let unknown_tag = Uuid::new_v4(); + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags/{unknown_tag}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn attach_requires_authentication(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let resp = h + .app + .oneshot(common::post_json( + &format!("/api/v1/mangas/{manga_id}/tags"), + json!({ "name": "Anon" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "./migrations")] +async fn attach_404_on_unknown_manga(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{}/tags", Uuid::new_v4()), + json!({ "name": "Anything" }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn filter_by_tag_id_matches_only_tagged_mangas(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let tagged = common::seed_manga_via_api(&h.app, &cookie, "Tagged").await; + let _untagged = common::seed_manga_via_api(&h.app, &cookie, "Untagged").await; + + let attach = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{tagged}/tags"), + json!({ "name": "Recommendation" }), + &cookie, + )) + .await + .unwrap(); + let tag_id = common::body_json(attach).await["id"].as_str().unwrap().to_string(); + + let resp = h + .app + .oneshot(common::get(&format!("/api/v1/mangas?tag_id={tag_id}"))) + .await + .unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + assert_eq!(titles, vec!["Tagged"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn tag_autocomplete_returns_matches_ordered_by_similarity(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + for name in ["Mystery", "Murder Mystery", "Comedy"] { + let _ = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + &format!("/api/v1/mangas/{manga_id}/tags"), + json!({ "name": name }), + &cookie, + )) + .await + .unwrap(); + } + + let resp = h + .app + .oneshot(common::get("/api/v1/tags?search=myst")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let names: Vec<&str> = body + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + assert!(names.iter().any(|n| *n == "Mystery")); + assert!(names.iter().any(|n| *n == "Murder Mystery")); + assert!(!names.iter().any(|n| *n == "Comedy")); +} diff --git a/frontend/package.json b/frontend/package.json index b7d9ec6..8616b45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.14.0", + "version": "0.15.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index bc418f9..fc8db66 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -65,13 +65,16 @@ export async function request(path: string, init?: RequestInit): Promise { export type Manga = { id: string; title: string; - author: string | null; + status: MangaStatus; + alt_titles: string[]; description: string | null; cover_image_path: string | null; created_at: string; updated_at: string; }; +export type MangaStatus = 'ongoing' | 'completed'; + export type Page = { limit: number; offset: number; diff --git a/frontend/src/lib/api/genres.test.ts b/frontend/src/lib/api/genres.test.ts new file mode 100644 index 0000000..b5fe647 --- /dev/null +++ b/frontend/src/lib/api/genres.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { listGenres } from './genres'; + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +describe('genres api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listGenres GETs /v1/genres and returns a flat array', async () => { + fetchSpy.mockResolvedValueOnce( + ok([ + { id: 'g1', name: 'Action' }, + { id: 'g2', name: 'Comedy' } + ]) + ); + const genres = await listGenres(); + expect(genres.map((g) => g.name)).toEqual(['Action', 'Comedy']); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/genres$/); + }); +}); diff --git a/frontend/src/lib/api/genres.ts b/frontend/src/lib/api/genres.ts new file mode 100644 index 0000000..138faf2 --- /dev/null +++ b/frontend/src/lib/api/genres.ts @@ -0,0 +1,9 @@ +import { request } from './client'; +import type { GenreRef } from './mangas'; + +export type Genre = GenreRef; + +/** Returns the full curated genre vocabulary. The list is short, so no pagination. */ +export async function listGenres(): Promise { + return request('/v1/genres'); +} diff --git a/frontend/src/lib/api/mangas.test.ts b/frontend/src/lib/api/mangas.test.ts index 567167a..71b2ebd 100644 --- a/frontend/src/lib/api/mangas.test.ts +++ b/frontend/src/lib/api/mangas.test.ts @@ -1,13 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; -import { listMangas, createManga, getManga } from './mangas'; +import { + listMangas, + createManga, + getManga, + updateManga, + attachTag, + detachTag +} from './mangas'; -function ok(body: unknown): Response { +function ok(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { - status: 200, + status, headers: { 'content-type': 'application/json' } }); } +function noContent(): Response { + return new Response(null, { status: 204 }); +} + function envelope(status: number, code: string, message: string): Response { return new Response(JSON.stringify({ error: { code, message } }), { status, @@ -19,6 +30,30 @@ function emptyPage() { return { items: [], page: { limit: 50, offset: 0, total: null } }; } +function cardFixture(extra: Record = {}) { + return { + id: 'b1', + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'a1', name: 'Kentaro Miura' }], + genres: [], + ...extra + }; +} + +function detailFixture(extra: Record = {}) { + return { + ...cardFixture(), + tags: [], + ...extra + }; +} + describe('mangas api client', () => { let fetchSpy: MockInstance; @@ -37,54 +72,65 @@ describe('mangas api client', () => { expect(url).toMatch(/\/v1\/mangas$/); }); - it('listMangas returns the paged envelope', async () => { + it('listMangas returns the paged envelope with cards', async () => { fetchSpy.mockResolvedValueOnce( ok({ - items: [ - { - id: 'b1', - title: 'Berserk', - author: 'Miura', - description: null, - cover_image_path: null, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' - } - ], - page: { limit: 50, offset: 0, total: null } + items: [cardFixture()], + page: { limit: 50, offset: 0, total: 1 } }) ); const result = await listMangas(); expect(result.items).toHaveLength(1); expect(result.items[0].title).toBe('Berserk'); - expect(result.page).toEqual({ limit: 50, offset: 0, total: null }); + expect(result.items[0].authors[0].name).toBe('Kentaro Miura'); + expect(result.page).toEqual({ limit: 50, offset: 0, total: 1 }); }); - it('listMangas encodes search, limit, offset, sort', async () => { + it('listMangas encodes search, status, ids (csv), limit, offset, sort', async () => { fetchSpy.mockResolvedValueOnce(ok(emptyPage())); - await listMangas({ search: 'one piece', limit: 10, offset: 20, sort: 'title' }); + await listMangas({ + search: 'one piece', + status: 'completed', + authorIds: ['a1', 'a2'], + genreIds: ['g1'], + tagIds: ['t1', 't2'], + limit: 10, + offset: 20, + sort: 'title' + }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\?/); expect(url).toContain('search=one+piece'); + expect(url).toContain('status=completed'); + // Multi-value facets land as a single comma-separated param so the + // backend can apply pure AND semantics across the list. + expect(url).toContain('author_id=a1%2Ca2'); + expect(url).toContain('genre_id=g1'); + expect(url).toContain('tag_id=t1%2Ct2'); expect(url).toContain('limit=10'); expect(url).toContain('offset=20'); expect(url).toContain('sort=title'); }); - it('createManga POSTs multipart with metadata to /v1/mangas', async () => { + it('getManga returns the enriched detail shape', async () => { fetchSpy.mockResolvedValueOnce( - ok({ - id: 'abc', - title: 'Berserk', - author: 'Miura', - description: null, - cover_image_path: null, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' - }) + ok(detailFixture({ tags: [{ id: 't1', name: 'Seinen', added_by: 'u1' }] })) ); - const m = await createManga({ title: 'Berserk', author: 'Miura' }); - expect(m.title).toBe('Berserk'); + const m = await getManga('b1'); + expect(m.tags).toHaveLength(1); + expect(m.tags[0].name).toBe('Seinen'); + expect(m.authors[0].name).toBe('Kentaro Miura'); + }); + + it('createManga POSTs multipart with the new metadata shape', async () => { + fetchSpy.mockResolvedValueOnce(ok(detailFixture())); + await createManga({ + title: 'Berserk', + authors: ['Kentaro Miura'], + status: 'completed', + alt_titles: ['ベルセルク'], + genre_ids: ['g1'] + }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas$/); const init = fetchSpy.mock.calls[0][1] as RequestInit; @@ -94,13 +140,17 @@ describe('mangas api client', () => { const metadata = form.get('metadata') as Blob; expect(metadata).toBeInstanceOf(Blob); expect(metadata.type).toBe('application/json'); - // jsdom doesn't implement Blob.text(); read the bytes via FileReader. const text = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.readAsText(metadata); }); - expect(text).toBe(JSON.stringify({ title: 'Berserk', author: 'Miura' })); + const parsed = JSON.parse(text); + expect(parsed.title).toBe('Berserk'); + expect(parsed.authors).toEqual(['Kentaro Miura']); + expect(parsed.status).toBe('completed'); + expect(parsed.alt_titles).toEqual(['ベルセルク']); + expect(parsed.genre_ids).toEqual(['g1']); expect(form.get('cover')).toBeNull(); // The browser sets Content-Type with boundary automatically when body // is a FormData — we must NOT set it ourselves. @@ -108,17 +158,7 @@ describe('mangas api client', () => { }); it('createManga attaches the cover Blob when supplied', async () => { - fetchSpy.mockResolvedValueOnce( - ok({ - id: 'abc', - title: 'Berserk', - author: null, - description: null, - cover_image_path: 'mangas/abc/cover.png', - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' - }) - ); + fetchSpy.mockResolvedValueOnce(ok(detailFixture())); const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }); await createManga({ title: 'Berserk' }, cover); const init = fetchSpy.mock.calls[0][1] as RequestInit; @@ -127,6 +167,45 @@ describe('mangas api client', () => { expect(got).toBeInstanceOf(Blob); }); + it('updateManga PATCHes with the provided patch body', async () => { + fetchSpy.mockResolvedValueOnce(ok(detailFixture({ status: 'completed' }))); + const updated = await updateManga('b1', { + status: 'completed', + authors: ['Kentaro Miura', 'Studio Gaga'] + }); + expect(updated.status).toBe('completed'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/b1$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ + status: 'completed', + authors: ['Kentaro Miura', 'Studio Gaga'] + }); + }); + + it('attachTag POSTs the name and returns the TagRef', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201) + ); + const tag = await attachTag('b1', 'Dark Fantasy'); + expect(tag.name).toBe('Dark Fantasy'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/b1\/tags$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ name: 'Dark Fantasy' }); + }); + + it('detachTag DELETEs the (manga, tag) pair', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await detachTag('b1', 't9'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/mangas\/b1\/tags\/t9$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + }); + it('getManga throws ApiError carrying the envelope code on non-2xx', async () => { fetchSpy.mockResolvedValue(envelope(404, 'not_found', 'manga not found')); await expect(getManga('missing')).rejects.toMatchObject({ diff --git a/frontend/src/lib/api/mangas.ts b/frontend/src/lib/api/mangas.ts index 5629564..f42760f 100644 --- a/frontend/src/lib/api/mangas.ts +++ b/frontend/src/lib/api/mangas.ts @@ -1,22 +1,54 @@ -import { request, type Manga, type Page } from './client'; +import { request, type Manga, type MangaStatus, type Page } from './client'; export type MangaSort = 'recent' | 'title'; +export type AuthorRef = { id: string; name: string }; +export type GenreRef = { id: string; name: string }; +export type TagRef = { id: string; name: string; added_by: string | null }; + +/** Card shape returned by `GET /v1/mangas` — authors + genres, no tags. */ +export type MangaCard = Manga & { + authors: AuthorRef[]; + genres: GenreRef[]; +}; + +/** Detail shape returned by `GET /v1/mangas/:id`. Includes user tags. */ +export type MangaDetail = Manga & { + authors: AuthorRef[]; + genres: GenreRef[]; + tags: TagRef[]; +}; + export type ListOptions = { search?: string; + status?: MangaStatus; + /** AND across the list — every id must be attached to the manga. */ + authorIds?: string[]; + genreIds?: string[]; + tagIds?: string[]; limit?: number; offset?: number; sort?: MangaSort; }; export type MangasPage = { - items: Manga[]; + items: MangaCard[]; page: Page; }; export async function listMangas(opts: ListOptions = {}): Promise { const params = new URLSearchParams(); if (opts.search) params.set('search', opts.search); + if (opts.status) params.set('status', opts.status); + if (opts.authorIds && opts.authorIds.length) { + params.set('author_id', opts.authorIds.join(',')); + } + if (opts.genreIds && opts.genreIds.length) { + params.set('genre_id', opts.genreIds.join(',')); + } + if (opts.tagIds && opts.tagIds.length) { + params.set('tag_id', opts.tagIds.join(',')); + } if (opts.limit != null) params.set('limit', String(opts.limit)); if (opts.offset != null) params.set('offset', String(opts.offset)); if (opts.sort) params.set('sort', opts.sort); @@ -24,14 +56,18 @@ export async function listMangas(opts: ListOptions = {}): Promise { return request(`/v1/mangas${qs ? `?${qs}` : ''}`); } -export async function getManga(id: string): Promise { - return request(`/v1/mangas/${encodeURIComponent(id)}`); +export async function getManga(id: string): Promise { + return request(`/v1/mangas/${encodeURIComponent(id)}`); } export type NewManga = { title: string; - author?: string | null; + status?: MangaStatus; + /** Author display names; resolved server-side, case-insensitive. */ + authors?: string[]; description?: string | null; + alt_titles?: string[]; + genre_ids?: string[]; }; /** @@ -40,14 +76,55 @@ export type NewManga = { * automatically when `body` is a FormData, so we deliberately do not set * Content-Type ourselves. */ -export async function createManga(input: NewManga, cover?: Blob): Promise { +export async function createManga( + input: NewManga, + cover?: Blob +): Promise { const form = new FormData(); form.append( 'metadata', new Blob([JSON.stringify(input)], { type: 'application/json' }) ); if (cover) form.append('cover', cover); - return request('/v1/mangas', { method: 'POST', body: form }); + return request('/v1/mangas', { method: 'POST', body: form }); } -export type { Manga, Page }; +export type MangaPatch = { + title?: string; + status?: MangaStatus; + description?: string | null; + alt_titles?: string[]; + authors?: string[]; + genre_ids?: string[]; +}; + +export async function updateManga( + id: string, + patch: MangaPatch +): Promise { + return request(`/v1/mangas/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch) + }); +} + +export async function attachTag( + mangaId: string, + name: string +): Promise { + return request(`/v1/mangas/${encodeURIComponent(mangaId)}/tags`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name }) + }); +} + +export async function detachTag(mangaId: string, tagId: string): Promise { + await request( + `/v1/mangas/${encodeURIComponent(mangaId)}/tags/${encodeURIComponent(tagId)}`, + { method: 'DELETE' } + ); +} + +export type { Manga, MangaStatus, Page }; diff --git a/frontend/src/lib/api/tags.test.ts b/frontend/src/lib/api/tags.test.ts new file mode 100644 index 0000000..9ec179d --- /dev/null +++ b/frontend/src/lib/api/tags.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { listTags } from './tags'; + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +describe('tags api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listTags GETs /v1/tags with no query when no opts', async () => { + fetchSpy.mockResolvedValueOnce(ok([])); + await listTags(); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/tags$/); + }); + + it('listTags encodes search + limit', async () => { + fetchSpy.mockResolvedValueOnce( + ok([{ id: 't1', name: 'Mystery', created_at: '2026-01-01T00:00:00Z' }]) + ); + const tags = await listTags({ search: 'myst', limit: 5 }); + expect(tags[0].name).toBe('Mystery'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('search=myst'); + expect(url).toContain('limit=5'); + }); +}); diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts new file mode 100644 index 0000000..b7a7f05 --- /dev/null +++ b/frontend/src/lib/api/tags.ts @@ -0,0 +1,21 @@ +import { request } from './client'; + +export type Tag = { + id: string; + name: string; + created_at: string; +}; + +export type ListTagsOptions = { + search?: string; + limit?: number; +}; + +/** Autocomplete for the tag input. Server sorts by trigram similarity. */ +export async function listTags(opts: ListTagsOptions = {}): Promise { + const params = new URLSearchParams(); + if (opts.search) params.set('search', opts.search); + if (opts.limit != null) params.set('limit', String(opts.limit)); + const qs = params.toString(); + return request(`/v1/tags${qs ? `?${qs}` : ''}`); +} diff --git a/frontend/src/lib/components/Chip.svelte b/frontend/src/lib/components/Chip.svelte new file mode 100644 index 0000000..b4f2cb4 --- /dev/null +++ b/frontend/src/lib/components/Chip.svelte @@ -0,0 +1,118 @@ + + +{#if href} + + {label} + {#if onRemove} + + {/if} + +{:else} + + {label} + {#if onRemove} + + {/if} + +{/if} + + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 082532f..03605b3 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,23 +1,56 @@

Mangas

{ - e.preventDefault(); - load(); - }} + onsubmit={onSubmit} action="javascript:void(0)" class="controls" > @@ -54,10 +223,147 @@ placeholder="Search by title or author" data-testid="search-input" /> - + + {#if filtersOpen} +
+
+ Status +
+ + + +
+
+ +
+ Genres (all must match) +
+ {#each allGenres as g (g.id)} + {@const on = selectedGenres.some((x) => x.id === g.id)} + + {/each} +
+
+ +
+ Tags (all must match) + {#if selectedTags.length > 0} +
+ {#each selectedTags as t (t.id)} + removeTag(t)} + removeLabel={`Remove tag ${t.name}`} + testid={`tag-filter-chip-${t.name}`} + /> + {/each} +
+ {/if} + +
+ + {#if activeFilterCount > 0} + + {/if} +
+ {/if}