Files
Mangalord/backend/src/repo/read_progress.rs
MechaCat02 19c1276490 feat: read & upload history (0.19.0)
Per-user reading progress and uploader attribution.

Schema (migration 0011): `read_progress` table (one row per (user,
manga); chapter_id nullable on chapter delete) and nullable
`uploaded_by` columns on mangas + chapters with partial indexes
scoped to non-null rows.

Endpoints (all `/me/*`, auth-scoped):
- PUT `/v1/me/read-progress` upserts. FK violations + cross-manga
  chapter ids both surface as 4xx (404 / 422) so the API can't be
  used to write logically invalid rows.
- GET `/v1/me/read-progress` paged newest-first list.
- GET `/v1/me/read-progress/:manga_id` enriched with chapter_number
  for the manga page's Continue CTA.
- DELETE `/v1/me/read-progress/:manga_id` idempotent.
- GET `/v1/me/uploads` interleaved manga + chapter uploads as a
  tagged union; limit-only pagination.

Existing manga + chapter upload handlers stamp `uploaded_by`.

Frontend:
- Reader emits progress on mount + page change (debounce) and via
  IntersectionObserver in continuous mode. High-water mark is seeded
  from the persisted server value so re-opening a chapter doesn't
  regress to page 1. Tab close survives via `sendBeacon` (fallback
  `keepalive` fetch); SPA navigation flushes via regular fetch.
- Manga detail page shows "Continue reading Chapter N — page M"
  above the chapters list, working even for mangas with >50
  chapters.
- New `/profile/history` tab with reading history (clear-per-row,
  inline error on failure) and uploads (mangas + chapters mixed
  chronologically with type-aware rendering).

171 backend tests (incl. 16 history tests covering ownership, FK
race, cross-link guard, chapter SET NULL behaviour) and 97 frontend
tests + svelte-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:19:52 +02:00

165 lines
4.6 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
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,
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(())
}