//! Chapter persistence. use sqlx::{PgExecutor, PgPool}; use uuid::Uuid; use crate::domain::Chapter; use crate::error::{AppError, AppResult}; pub async fn list_for_manga( pool: &PgPool, manga_id: Uuid, limit: i64, offset: i64, ) -> AppResult> { // Secondary sort by created_at gives duplicate-numbered chapters // (multiple uploaders/translations of the same number) a stable // order in lists and prev/next reader navigation. let rows = sqlx::query_as::<_, Chapter>( r#" SELECT id, manga_id, number, title, page_count, created_at FROM chapters WHERE manga_id = $1 ORDER BY number ASC, created_at ASC LIMIT $2 OFFSET $3 "#, ) .bind(manga_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(rows) } /// Look up a chapter by its UUID, scoped to its manga so a UUID guessed /// from a different manga's URL doesn't accidentally resolve. pub async fn find_by_id_in_manga( pool: &PgPool, manga_id: Uuid, chapter_id: Uuid, ) -> AppResult> { let row = sqlx::query_as::<_, Chapter>( r#" SELECT id, manga_id, number, title, page_count, created_at FROM chapters WHERE manga_id = $1 AND id = $2 "#, ) .bind(manga_id) .bind(chapter_id) .fetch_optional(pool) .await?; Ok(row) } /// Accepts any `PgExecutor` so the upload handler can run this inside a /// transaction with the per-page inserts. /// /// `uploaded_by` records who uploaded the chapter and feeds the /// per-user upload history. `None` means "historical / API token with /// no associated user" — kept nullable to support that case. /// /// Chapter identity is the row UUID; the same (manga_id, number) /// combination can repeat (multiple translations, re-uploads). The /// `is_unique_violation` branch below is a defensive holdover from /// 0001's (manga_id, number) UNIQUE — it can no longer fire under /// normal operation, but we surface a clean 409 if a future migration /// re-adds any chapter uniqueness. pub async fn create<'e, E: PgExecutor<'e>>( executor: E, manga_id: Uuid, number: i32, title: Option<&str>, uploaded_by: Option, ) -> AppResult { let result = sqlx::query_as::<_, Chapter>( r#" INSERT INTO chapters (manga_id, number, title, uploaded_by) VALUES ($1, $2, $3, $4) RETURNING id, manga_id, number, title, page_count, created_at "#, ) .bind(manga_id) .bind(number) .bind(title) .bind(uploaded_by) .fetch_one(executor) .await; match result { Ok(c) => Ok(c), Err(e) if is_unique_violation(&e) => Err(AppError::Conflict(format!( "chapter {number} conflicts with an existing chapter for this manga" ))), Err(e) => Err(AppError::Database(e)), } } pub async fn set_page_count<'e, E: PgExecutor<'e>>( executor: E, id: Uuid, page_count: i32, ) -> AppResult<()> { sqlx::query("UPDATE chapters SET page_count = $1 WHERE id = $2") .bind(page_count) .bind(id) .execute(executor) .await?; Ok(()) } fn is_unique_violation(err: &sqlx::Error) -> bool { if let sqlx::Error::Database(db_err) = err { db_err.code().as_deref() == Some("23505") } else { false } }