A "Continue reading" shelf listing series the reader already finished (read to the last page, nothing new) is odd. Expose the last-read chapter's page_count on the read-progress list, and filter the shelf to drop entries that are caught up (on the last page of the latest chapter with no new chapters). Mid-chapter and has-new-chapters entries stay; the full history view is unaffected. Finished detection is a pure, unit-tested helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
6.0 KiB
Rust
190 lines
6.0 KiB
Rust
//! 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<Uuid>,
|
|
page: i32,
|
|
) -> AppResult<ReadProgress> {
|
|
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<ReadProgress> {
|
|
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<ReadProgressForManga> {
|
|
sqlx::query_as::<_, ReadProgressForManga>(
|
|
r#"
|
|
SELECT rp.manga_id,
|
|
rp.chapter_id,
|
|
c.number AS chapter_number,
|
|
rp.page,
|
|
rp.updated_at,
|
|
-- Distinct chapter numbers past the reader's last-read chapter
|
|
-- (see list_for_user for the non-unique-number rationale). 0
|
|
-- when the last-read chapter is unknown.
|
|
(
|
|
SELECT count(DISTINCT c2.number)
|
|
FROM chapters c2
|
|
WHERE c2.manga_id = rp.manga_id
|
|
AND c.number IS NOT NULL
|
|
AND c2.number > c.number
|
|
) AS new_chapters_count
|
|
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<bool> {
|
|
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<ReadProgressSummary>, 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,
|
|
c.page_count AS chapter_page_count,
|
|
rp.page,
|
|
rp.updated_at,
|
|
-- Personal "new since last read": how many distinct chapter
|
|
-- numbers sit past the reader's last-read chapter.
|
|
-- COUNT(DISTINCT number) — not COUNT(*) — because
|
|
-- (manga_id, number) is non-unique (scanlations share a
|
|
-- number, migration 0013), so raw rows would over-count. When
|
|
-- `c.number` is NULL (manga-level progress or a deleted
|
|
-- chapter) the predicate matches no rows, yielding 0.
|
|
(
|
|
SELECT count(DISTINCT c2.number)
|
|
FROM chapters c2
|
|
WHERE c2.manga_id = rp.manga_id
|
|
AND c.number IS NOT NULL
|
|
AND c2.number > c.number
|
|
) AS new_chapters_count
|
|
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(())
|
|
}
|