//! Per-user reading-progress persistence. use sqlx::PgPool; use uuid::Uuid; use crate::domain::read_progress::{ ReadProgress, ReadProgressForManga, ReadProgressSummary, }; use crate::error::{AppError, AppResult}; /// Insert-or-overwrite the user's progress row for this manga. /// Progress can move backwards (re-reading) — we accept the /// simplification that the last write wins. /// /// FK violations (manga or chapter deleted between the handler's /// existence check and this write) are mapped to `NotFound` so the /// API returns 404 rather than 500. pub async fn upsert( pool: &PgPool, user_id: Uuid, manga_id: Uuid, chapter_id: Option, page: i32, ) -> AppResult { sqlx::query_as::<_, ReadProgress>( r#" INSERT INTO read_progress (user_id, manga_id, chapter_id, page, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (user_id, manga_id) DO UPDATE SET chapter_id = EXCLUDED.chapter_id, page = EXCLUDED.page, updated_at = now() RETURNING user_id, manga_id, chapter_id, page, updated_at "#, ) .bind(user_id) .bind(manga_id) .bind(chapter_id) .bind(page) .fetch_one(pool) .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), }) } pub async fn get( pool: &PgPool, user_id: Uuid, manga_id: Uuid, ) -> AppResult { sqlx::query_as::<_, ReadProgress>( r#" SELECT user_id, manga_id, chapter_id, page, updated_at FROM read_progress WHERE user_id = $1 AND manga_id = $2 "#, ) .bind(user_id) .bind(manga_id) .fetch_optional(pool) .await? .ok_or(AppError::NotFound) } /// Same lookup as `get`, but resolves `chapter_number` in one round- /// trip so the manga detail page's "Continue reading" CTA can render /// without having to find the chapter in the paged chapters list. pub async fn get_for_manga( pool: &PgPool, user_id: Uuid, manga_id: Uuid, ) -> AppResult { sqlx::query_as::<_, ReadProgressForManga>( r#" SELECT rp.manga_id, rp.chapter_id, c.number AS chapter_number, rp.page, rp.updated_at FROM read_progress rp LEFT JOIN chapters c ON c.id = rp.chapter_id WHERE rp.user_id = $1 AND rp.manga_id = $2 "#, ) .bind(user_id) .bind(manga_id) .fetch_optional(pool) .await? .ok_or(AppError::NotFound) } /// Cross-link guard. Returns true when `chapter_id` belongs to /// `manga_id`. The upsert handler calls this before writing to refuse /// PUT bodies that pair a chapter from one manga with another manga /// — the FK alone can't catch that because both ids resolve /// individually. pub async fn chapter_belongs_to_manga( pool: &PgPool, manga_id: Uuid, chapter_id: Uuid, ) -> AppResult { let (matches,): (bool,) = sqlx::query_as( r#" SELECT EXISTS( SELECT 1 FROM chapters WHERE id = $1 AND manga_id = $2 ) "#, ) .bind(chapter_id) .bind(manga_id) .fetch_one(pool) .await?; Ok(matches) } pub async fn list_for_user( pool: &PgPool, user_id: Uuid, limit: i64, offset: i64, ) -> AppResult<(Vec, i64)> { let rows = sqlx::query_as::<_, ReadProgressSummary>( r#" SELECT rp.manga_id, m.title AS manga_title, m.cover_image_path AS manga_cover_image_path, rp.chapter_id, c.number AS chapter_number, rp.page, rp.updated_at FROM read_progress rp JOIN mangas m ON m.id = rp.manga_id LEFT JOIN chapters c ON c.id = rp.chapter_id WHERE rp.user_id = $1 ORDER BY rp.updated_at DESC, rp.manga_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 read_progress WHERE user_id = $1") .bind(user_id) .fetch_one(pool) .await?; Ok((rows, total)) } pub async fn delete(pool: &PgPool, user_id: Uuid, manga_id: Uuid) -> AppResult<()> { sqlx::query("DELETE FROM read_progress WHERE user_id = $1 AND manga_id = $2") .bind(user_id) .bind(manga_id) .execute(pool) .await?; Ok(()) }