//! 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, CollectionPageItem, 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 { 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 { 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> { 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, 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 { 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 { 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, 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> { 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()) } /// Add a page to a collection. Same `(true → 201, false → 200)` /// idempotency contract as `add_manga`. FK violations (page deleted /// between the handler's existence check and this insert) surface as /// `NotFound`, not a 500. pub async fn add_page( pool: &PgPool, collection_id: Uuid, page_id: Uuid, ) -> AppResult { let mut tx = pool.begin().await?; let inserted = sqlx::query( r#" INSERT INTO collection_pages (collection_id, page_id) VALUES ($1, $2) ON CONFLICT DO NOTHING "#, ) .bind(collection_id) .bind(page_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_page( pool: &PgPool, collection_id: Uuid, page_id: Uuid, ) -> AppResult<()> { let mut tx = pool.begin().await?; let rows_affected = sqlx::query( "DELETE FROM collection_pages WHERE collection_id = $1 AND page_id = $2", ) .bind(collection_id) .bind(page_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(()) } /// Paged list of `collection_id`'s pages, JOINed through chapters and /// mangas so each row carries the breadcrumb the detail view needs. pub async fn list_pages( pool: &PgPool, collection_id: Uuid, limit: i64, offset: i64, ) -> AppResult<(Vec, i64)> { let rows = sqlx::query_as::<_, CollectionPageItem>( r#" SELECT p.id AS page_id, p.chapter_id AS chapter_id, ch.manga_id AS manga_id, p.page_number AS page_number, ch.number AS chapter_number, ch.title AS chapter_title, m.title AS manga_title, p.storage_key AS storage_key, cp.added_at AS added_at FROM collection_pages cp JOIN pages p ON p.id = cp.page_id JOIN chapters ch ON ch.id = p.chapter_id JOIN mangas m ON m.id = ch.manga_id WHERE cp.collection_id = $1 ORDER BY cp.added_at DESC, p.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_pages WHERE collection_id = $1") .bind(collection_id) .fetch_one(pool) .await?; Ok((rows, total)) } /// Which of `user_id`'s collections currently contain `page_id`? /// Powers the reader context menu's "In N collections" line and the /// "Add to collection" pre-check. pub async fn list_collections_containing_page( pool: &PgPool, user_id: Uuid, page_id: Uuid, ) -> AppResult> { let rows: Vec<(Uuid,)> = sqlx::query_as( r#" SELECT c.id FROM collections c JOIN collection_pages cp ON cp.collection_id = c.id WHERE c.user_id = $1 AND cp.page_id = $2 ORDER BY c.updated_at DESC "#, ) .bind(user_id) .bind(page_id) .fetch_all(pool) .await?; Ok(rows.into_iter().map(|(id,)| id).collect()) }