//! Admin manga/chapter overview with derived sync state. //! //! Sync state comes from `repo::admin_view`, which joins the manga / //! chapter tables with the crawler signals at query time — there is no //! persisted sync_state column. See [`repo::admin_view`] for the //! derivation priority order. use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; use serde::Deserialize; use uuid::Uuid; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::RequireAdmin; use crate::domain::MangaSyncState; use crate::error::{AppError, AppResult}; use crate::repo; use crate::repo::admin_view::{AdminChapterRow, AdminMangaRow}; pub fn routes() -> Router { Router::new() .route("/admin/mangas", get(list_mangas)) .route("/admin/mangas/:id/chapters", get(list_chapters)) } #[derive(Debug, Deserialize, Default)] pub struct ListChaptersParams { #[serde(default = "default_chapter_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_chapter_limit() -> i64 { 200 } #[derive(Debug, Deserialize, Default)] pub struct ListMangasParams { #[serde(default)] pub search: Option, /// `in_progress` | `dropped` | `synced`. Unrecognised values are a 400. #[serde(default)] pub sync_state: Option, #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_limit() -> i64 { 50 } async fn list_mangas( State(state): State, _admin: RequireAdmin, Query(params): Query, ) -> AppResult>> { let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); let sync_state = match params.sync_state.as_deref() { None | Some("") => None, Some("in_progress") => Some(MangaSyncState::InProgress), Some("dropped") => Some(MangaSyncState::Dropped), Some("synced") => Some(MangaSyncState::Synced), Some(other) => { return Err(AppError::InvalidInput(format!( "sync_state must be one of in_progress|dropped|synced (got {other:?})" ))); } }; let q = repo::admin_view::ListAdminMangasQuery { search: params.search.filter(|s| !s.trim().is_empty()), sync_state, limit, offset, }; let (items, total) = repo::admin_view::list_mangas_with_sync_state(&state.db, &q).await?; Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } async fn list_chapters( State(state): State, _admin: RequireAdmin, Path(manga_id): Path, Query(params): Query, ) -> AppResult>> { // Explicit existence check so a typo / deleted manga returns 404 // rather than a misleading "no chapters" 200. if !repo::manga::exists(&state.db, manga_id).await? { return Err(AppError::NotFound); } // Cap at 500 to bound the per-row scalar-subquery cost on // long-runners with thousands of chapters; default 200 covers // typical browsing without paging round-trips. let limit = params.limit.clamp(1, 500); let offset = params.offset.max(0); let q = repo::admin_view::ListAdminChaptersQuery { manga_id, limit, offset, }; let (items, total) = repo::admin_view::list_chapters_with_sync_state(&state.db, &q).await?; Ok(Json(PagedResponse::with_total(items, limit, offset, total))) }