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:
280
backend/src/repo/collection.rs
Normal file
280
backend/src/repo/collection.rs
Normal 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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user