//! 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 { 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, CurrentUser(user): CurrentUser, Json(input): Json, ) -> AppResult> { 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, CurrentUser(user): CurrentUser, Query(params): Query, ) -> AppResult>> { 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, CurrentUser(user): CurrentUser, Path(manga_id): Path, ) -> AppResult> { // 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, CurrentUser(user): CurrentUser, Path(manga_id): Path, ) -> AppResult { 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, CurrentUser(user): CurrentUser, Query(params): Query, ) -> AppResult>> { // 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))) }