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>
146 lines
4.4 KiB
Rust
146 lines
4.4 KiB
Rust
//! 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)))
|
|
}
|