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:
MechaCat02
2026-05-17 17:43:06 +02:00
parent 5e92a2c450
commit 274cc819ca
24 changed files with 2689 additions and 100 deletions

View File

@@ -0,0 +1,280 @@
//! Collection persistence.
//!
//! Same plain-function pattern as `repo::bookmark`. Ownership is
//! tracked via `collections.user_id`; handlers call `find_owner`
//! before mutations to keep 403/404 honest.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::collection::{Collection, CollectionSummary};
use crate::domain::manga::Manga;
use crate::error::{AppError, AppResult};
pub async fn create(
pool: &PgPool,
user_id: Uuid,
name: &str,
description: Option<&str>,
) -> AppResult<Collection> {
let row = sqlx::query_as::<_, Collection>(
r#"
INSERT INTO collections (user_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, user_id, name, description, created_at, updated_at
"#,
)
.bind(user_id)
.bind(name.trim())
.bind(description)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::Conflict("a collection with this name already exists".into())
}
other => AppError::Database(other),
})?;
Ok(row)
}
pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<Collection> {
sqlx::query_as::<_, Collection>(
r#"
SELECT id, user_id, name, description, created_at, updated_at
FROM collections
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)
}
pub async fn find_owner(pool: &PgPool, id: Uuid) -> AppResult<Option<Uuid>> {
let row: Option<(Uuid,)> =
sqlx::query_as("SELECT user_id FROM collections WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(u,)| u))
}
/// Paged list of one user's collections. Includes `manga_count` and up
/// to three sample cover image keys (newest-added first) so a card can
/// render without a follow-up fetch.
pub async fn list_for_user(
pool: &PgPool,
user_id: Uuid,
limit: i64,
offset: i64,
) -> AppResult<(Vec<CollectionSummary>, i64)> {
let rows = sqlx::query_as::<_, CollectionSummary>(
r#"
SELECT
c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
(SELECT count(*) FROM collection_mangas cm WHERE cm.collection_id = c.id)
AS manga_count,
COALESCE(
(
-- `array_agg(... ORDER BY ...)` is the only
-- spec-guaranteed way to preserve element order;
-- a subquery's ORDER BY isn't a contract the
-- outer aggregate has to honour. Adding manga_id
-- as a tiebreaker keeps the order stable when
-- multiple rows share `added_at` (bulk imports).
SELECT array_agg(cover_image_path ORDER BY added_at DESC, manga_id)
FROM (
SELECT m.cover_image_path, cm2.added_at, cm2.manga_id
FROM collection_mangas cm2
JOIN mangas m ON m.id = cm2.manga_id
WHERE cm2.collection_id = c.id
AND m.cover_image_path IS NOT NULL
ORDER BY cm2.added_at DESC, cm2.manga_id
LIMIT 3
) p
),
ARRAY[]::text[]
) AS sample_covers
FROM collections c
WHERE c.user_id = $1
ORDER BY c.updated_at DESC, c.id
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) =
sqlx::query_as("SELECT count(*) FROM collections WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
pub async fn update(
pool: &PgPool,
id: Uuid,
name: Option<&str>,
description_provided: bool,
description: Option<&str>,
) -> AppResult<Collection> {
let row = sqlx::query_as::<_, Collection>(
r#"
UPDATE collections
SET name = COALESCE($2, name),
description = CASE WHEN $3::boolean THEN $4 ELSE description END,
updated_at = now()
WHERE id = $1
RETURNING id, user_id, name, description, created_at, updated_at
"#,
)
.bind(id)
.bind(name.map(str::trim))
.bind(description_provided)
.bind(description)
.fetch_optional(pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_unique_violation() => {
AppError::Conflict("a collection with this name already exists".into())
}
other => AppError::Database(other),
})?
.ok_or(AppError::NotFound)?;
Ok(row)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> AppResult<()> {
sqlx::query("DELETE FROM collections WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Add a manga to a collection. Returns `true` if a new attachment was
/// created (handler picks 201), `false` if the manga was already in
/// the collection (handler picks 200). Touches `updated_at` so the
/// "recent collections" sort reflects activity.
///
/// FK violations (manga deleted between the handler's `exists` check
/// and this insert — a race the API can't fully close from the
/// outside) are remapped to `NotFound` so the handler returns 404
/// rather than 500.
pub async fn add_manga(
pool: &PgPool,
collection_id: Uuid,
manga_id: Uuid,
) -> AppResult<bool> {
let mut tx = pool.begin().await?;
let inserted = sqlx::query(
r#"
INSERT INTO collection_mangas (collection_id, manga_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
)
.bind(collection_id)
.bind(manga_id)
.execute(&mut *tx)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
AppError::NotFound
}
other => AppError::Database(other),
})?;
let rows_affected = inserted.rows_affected();
if rows_affected > 0 {
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
.bind(collection_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(rows_affected > 0)
}
pub async fn remove_manga(
pool: &PgPool,
collection_id: Uuid,
manga_id: Uuid,
) -> AppResult<()> {
let mut tx = pool.begin().await?;
let rows_affected = sqlx::query(
"DELETE FROM collection_mangas WHERE collection_id = $1 AND manga_id = $2",
)
.bind(collection_id)
.bind(manga_id)
.execute(&mut *tx)
.await?
.rows_affected();
if rows_affected > 0 {
sqlx::query("UPDATE collections SET updated_at = now() WHERE id = $1")
.bind(collection_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn list_mangas(
pool: &PgPool,
collection_id: Uuid,
limit: i64,
offset: i64,
) -> AppResult<(Vec<Manga>, 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 collection_mangas cm
JOIN mangas m ON m.id = cm.manga_id
WHERE cm.collection_id = $1
ORDER BY cm.added_at DESC, m.id
LIMIT $2 OFFSET $3
"#,
)
.bind(collection_id)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (total,): (i64,) =
sqlx::query_as("SELECT count(*) FROM collection_mangas WHERE collection_id = $1")
.bind(collection_id)
.fetch_one(pool)
.await?;
Ok((rows, total))
}
/// Which of `user_id`'s collections currently contain `manga_id`?
/// Used by the "Add to collection" modal to pre-check the boxes.
pub async fn list_collections_containing(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let rows: Vec<(Uuid,)> = sqlx::query_as(
r#"
SELECT c.id
FROM collections c
JOIN collection_mangas cm ON cm.collection_id = c.id
WHERE c.user_id = $1
AND cm.manga_id = $2
ORDER BY c.updated_at DESC
"#,
)
.bind(user_id)
.bind(manga_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}

View File

@@ -2,6 +2,7 @@ pub mod api_token;
pub mod author;
pub mod bookmark;
pub mod chapter;
pub mod collection;
pub mod genre;
pub mod manga;
pub mod page;