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>
This commit is contained in:
MechaCat02
2026-05-17 18:19:52 +02:00
parent 7560d59616
commit 19c1276490
31 changed files with 1927 additions and 17 deletions

View File

@@ -71,7 +71,7 @@ async fn get_one(
async fn create(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
mut multipart: Multipart,
) -> AppResult<(StatusCode, Json<Chapter>)> {
@@ -133,6 +133,7 @@ async fn create(
manga_id,
metadata.number,
metadata.title.as_deref(),
Some(user.id),
)
.await?;

145
backend/src/api/history.rs Normal file
View File

@@ -0,0 +1,145 @@
//! Reading-progress and upload-history endpoints (Phase 5).
//!
//! All routes live under `/me/...` and require `CurrentUser`. They
//! never expose another user's data — the user id is taken from the
//! auth extractor, not from the path or body.
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::read_progress::{
ReadProgress, ReadProgressForManga, ReadProgressSummary, UpsertReadProgress,
};
use crate::domain::upload_entry::UploadEntry;
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/me/read-progress", put(upsert).get(list))
.route(
"/me/read-progress/:manga_id",
get(get_one).delete(delete_one),
)
.route("/me/uploads", get(uploads))
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
50
}
async fn upsert(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<UpsertReadProgress>,
) -> AppResult<Json<ReadProgress>> {
let page = input.page.unwrap_or(1);
if page < 1 {
return Err(AppError::ValidationFailed {
message: "page must be 1 or greater".into(),
details: json!({ "page": "must be >= 1" }),
});
}
// Cross-link guard: the FKs on read_progress accept any valid
// (manga_id, chapter_id), even when they refer to unrelated mangas.
// Reject mismatched pairs so history can't end up rendering a
// chapter number from the wrong manga.
if let Some(chapter_id) = input.chapter_id {
let belongs = repo::read_progress::chapter_belongs_to_manga(
&state.db,
input.manga_id,
chapter_id,
)
.await?;
if !belongs {
return Err(AppError::ValidationFailed {
message: "chapter does not belong to this manga".into(),
details: json!({ "chapter_id": "must reference a chapter of the supplied manga" }),
});
}
}
let row = repo::read_progress::upsert(
&state.db,
user.id,
input.manga_id,
input.chapter_id,
page,
)
.await?;
Ok(Json(row))
}
async fn list(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<ReadProgressSummary>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::read_progress::list_for_user(&state.db, user.id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn get_one(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<ReadProgressForManga>> {
// Enriched with `chapter_number` so the manga page's Continue
// CTA doesn't need to resolve the chapter id against the paged
// chapters list.
Ok(Json(
repo::read_progress::get_for_manga(&state.db, user.id, manga_id).await?,
))
}
async fn delete_one(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<StatusCode> {
repo::read_progress::delete(&state.db, user.id, manga_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct UploadListParams {
#[serde(default = "default_uploads_limit")]
pub limit: i64,
}
fn default_uploads_limit() -> i64 {
50
}
async fn uploads(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<UploadListParams>,
) -> AppResult<Json<PagedResponse<UploadEntry>>> {
// Limit-only pagination for now — keyset across two unrelated
// tables is a future enhancement. Total comes from a fast count
// query so the UI can show "N total" without dragging the rows
// across the wire.
let limit = params.limit.clamp(1, 200);
let (items, total) =
repo::upload_history::list_for_user(&state.db, user.id, limit).await?;
Ok(Json(PagedResponse::with_total(items, limit, 0, total)))
}

View File

@@ -169,6 +169,7 @@ async fn create(
&status,
metadata.description.as_deref(),
&alt_titles,
Some(_user.id),
)
.await?;

View File

@@ -6,6 +6,7 @@ pub mod collections;
pub mod files;
pub mod genres;
pub mod health;
pub mod history;
pub mod mangas;
pub mod pagination;
pub mod tags;
@@ -26,4 +27,5 @@ pub fn routes() -> Router<AppState> {
.merge(tags::routes())
.merge(authors::routes())
.merge(collections::routes())
.merge(history::routes())
}