use axum::{extract::{Query, State}, Json}; use serde::{Deserialize, Serialize}; use sqlx::Row; use utoipa::ToSchema; use crate::{error::ApiError, state::AppState}; #[derive(Deserialize, ToSchema)] pub struct ListAuthorsQuery { #[schema(value_type = Option, example = "batman")] pub q: Option, #[schema(value_type = Option, example = 1)] pub page: Option, #[schema(value_type = Option, example = 20)] pub limit: Option, /// Sort order: "name" (default), "books" (most books first) #[schema(value_type = Option, example = "books")] pub sort: Option, } #[derive(Serialize, ToSchema)] pub struct AuthorItem { pub name: String, pub book_count: i64, pub series_count: i64, } #[derive(Serialize, ToSchema)] pub struct AuthorsPageResponse { pub items: Vec, pub total: i64, pub page: i64, pub limit: i64, } /// List all unique authors with book/series counts #[utoipa::path( get, path = "/authors", tag = "authors", params( ("q" = Option, Query, description = "Search by author name"), ("page" = Option, Query, description = "Page number (1-based)"), ("limit" = Option, Query, description = "Items per page (max 100)"), ("sort" = Option, Query, description = "Sort: name (default) or books"), ), responses( (status = 200, body = AuthorsPageResponse), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn list_authors( State(state): State, Query(query): Query, ) -> Result, ApiError> { let page = query.page.unwrap_or(1).max(1); let limit = query.limit.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * limit; let sort = query.sort.as_deref().unwrap_or("name"); let order_clause = match sort { "books" => "book_count DESC, name ASC", _ => "name ASC", }; let q_pattern = query.q.as_deref() .filter(|s| !s.trim().is_empty()) .map(|s| format!("%{s}%")); // Aggregate unique authors from books.authors + books.author let sql = format!( r#" WITH all_authors AS ( SELECT DISTINCT UNNEST( COALESCE( NULLIF(authors, '{{}}'), CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END ) ) AS name FROM books ), filtered AS ( SELECT name FROM all_authors WHERE ($1::text IS NULL OR name ILIKE $1) ), counted AS ( SELECT f.name, COUNT(DISTINCT b.id) AS book_count, COUNT(DISTINCT NULLIF(b.series, '')) AS series_count FROM filtered f JOIN books b ON ( f.name = ANY( COALESCE( NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END ) ) ) GROUP BY f.name ) SELECT name, book_count, series_count FROM counted ORDER BY {order_clause} LIMIT $2 OFFSET $3 "# ); let count_sql = r#" WITH all_authors AS ( SELECT DISTINCT UNNEST( COALESCE( NULLIF(authors, '{}'), CASE WHEN author IS NOT NULL AND author != '' THEN ARRAY[author] ELSE ARRAY[]::text[] END ) ) AS name FROM books ) SELECT COUNT(*) AS total FROM all_authors WHERE ($1::text IS NULL OR name ILIKE $1) "#; let (rows, count_row) = tokio::join!( sqlx::query(&sql) .bind(q_pattern.as_deref()) .bind(limit) .bind(offset) .fetch_all(&state.pool), sqlx::query(count_sql) .bind(q_pattern.as_deref()) .fetch_one(&state.pool) ); let rows = rows.map_err(|e| ApiError::internal(format!("authors query failed: {e}")))?; let total: i64 = count_row .map_err(|e| ApiError::internal(format!("authors count failed: {e}")))? .get("total"); let items: Vec = rows .iter() .map(|r| AuthorItem { name: r.get("name"), book_count: r.get("book_count"), series_count: r.get("series_count"), }) .collect(); Ok(Json(AuthorsPageResponse { items, total, page, limit, })) }