- API: add POST /series/mark-read to batch mark all books in a series - API: add GET /series cross-library endpoint with search, library and status filters - API: add library_id to SeriesItem response - Backoffice: mark book as read/unread button on book detail page - Backoffice: mark series as read/unread button on series cards - Backoffice: new /series top-level page with search and filters - Backoffice: new /libraries/[id]/series/[name] series detail page - Backoffice: opacity on fully read books and series cards - Backoffice: live search with debounce on books and series pages - Backoffice: reading status filter on books and series pages - Fix $2 -> $1 parameter binding in mark-series-read SQL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
7.2 KiB
Rust
248 lines
7.2 KiB
Rust
use axum::{extract::{Path, State}, Json};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::Row;
|
|
use uuid::Uuid;
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::{error::ApiError, state::AppState};
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct ReadingProgressResponse {
|
|
/// Reading status: "unread", "reading", or "read"
|
|
pub status: String,
|
|
/// Current page (only set when status is "reading")
|
|
pub current_page: Option<i32>,
|
|
#[schema(value_type = Option<String>)]
|
|
pub last_read_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct UpdateReadingProgressRequest {
|
|
/// Reading status: "unread", "reading", or "read"
|
|
pub status: String,
|
|
/// Required when status is "reading", must be > 0
|
|
pub current_page: Option<i32>,
|
|
}
|
|
|
|
/// Get reading progress for a book
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/books/{id}/progress",
|
|
tag = "reading-progress",
|
|
params(
|
|
("id" = String, Path, description = "Book UUID"),
|
|
),
|
|
responses(
|
|
(status = 200, body = ReadingProgressResponse),
|
|
(status = 404, description = "Book not found"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_reading_progress(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
|
// Verify book exists
|
|
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
|
.bind(id)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
if !exists {
|
|
return Err(ApiError::not_found("book not found"));
|
|
}
|
|
|
|
let row = sqlx::query(
|
|
"SELECT status, current_page, last_read_at FROM book_reading_progress WHERE book_id = $1",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
let response = match row {
|
|
Some(r) => ReadingProgressResponse {
|
|
status: r.get("status"),
|
|
current_page: r.get("current_page"),
|
|
last_read_at: r.get("last_read_at"),
|
|
},
|
|
None => ReadingProgressResponse {
|
|
status: "unread".to_string(),
|
|
current_page: None,
|
|
last_read_at: None,
|
|
},
|
|
};
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Update reading progress for a book
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/books/{id}/progress",
|
|
tag = "reading-progress",
|
|
params(
|
|
("id" = String, Path, description = "Book UUID"),
|
|
),
|
|
request_body = UpdateReadingProgressRequest,
|
|
responses(
|
|
(status = 200, body = ReadingProgressResponse),
|
|
(status = 404, description = "Book not found"),
|
|
(status = 422, description = "Validation error (missing or invalid current_page for status 'reading')"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn update_reading_progress(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<UpdateReadingProgressRequest>,
|
|
) -> Result<Json<ReadingProgressResponse>, ApiError> {
|
|
// Validate status value
|
|
if !["unread", "reading", "read"].contains(&body.status.as_str()) {
|
|
return Err(ApiError::bad_request(format!(
|
|
"invalid status '{}': must be one of unread, reading, read",
|
|
body.status
|
|
)));
|
|
}
|
|
|
|
// Validate current_page for "reading" status
|
|
if body.status == "reading" {
|
|
match body.current_page {
|
|
None => {
|
|
return Err(ApiError::unprocessable_entity(
|
|
"current_page is required when status is 'reading'",
|
|
))
|
|
}
|
|
Some(p) if p <= 0 => {
|
|
return Err(ApiError::unprocessable_entity(
|
|
"current_page must be greater than 0",
|
|
))
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Verify book exists
|
|
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM books WHERE id = $1)")
|
|
.bind(id)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
if !exists {
|
|
return Err(ApiError::not_found("book not found"));
|
|
}
|
|
|
|
// current_page is only stored for "reading" status
|
|
let current_page = if body.status == "reading" {
|
|
body.current_page
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let row = sqlx::query(
|
|
r#"
|
|
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
|
VALUES ($1, $2, $3, NOW(), NOW())
|
|
ON CONFLICT (book_id) DO UPDATE
|
|
SET status = EXCLUDED.status,
|
|
current_page = EXCLUDED.current_page,
|
|
last_read_at = NOW(),
|
|
updated_at = NOW()
|
|
RETURNING status, current_page, last_read_at
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.bind(&body.status)
|
|
.bind(current_page)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
|
|
Ok(Json(ReadingProgressResponse {
|
|
status: row.get("status"),
|
|
current_page: row.get("current_page"),
|
|
last_read_at: row.get("last_read_at"),
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct MarkSeriesReadRequest {
|
|
/// Series name (use "unclassified" for books without series)
|
|
pub series: String,
|
|
/// Status to set: "read" or "unread"
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct MarkSeriesReadResponse {
|
|
pub updated: i64,
|
|
}
|
|
|
|
/// Mark all books in a series as read or unread
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/series/mark-read",
|
|
tag = "reading-progress",
|
|
request_body = MarkSeriesReadRequest,
|
|
responses(
|
|
(status = 200, body = MarkSeriesReadResponse),
|
|
(status = 422, description = "Invalid status"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn mark_series_read(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<MarkSeriesReadRequest>,
|
|
) -> Result<Json<MarkSeriesReadResponse>, ApiError> {
|
|
if !["read", "unread"].contains(&body.status.as_str()) {
|
|
return Err(ApiError::bad_request(
|
|
"status must be 'read' or 'unread'",
|
|
));
|
|
}
|
|
|
|
let series_filter = if body.series == "unclassified" {
|
|
"(series IS NULL OR series = '')"
|
|
} else {
|
|
"series = $1"
|
|
};
|
|
|
|
let sql = if body.status == "unread" {
|
|
// Delete progress records to reset to unread
|
|
format!(
|
|
r#"
|
|
WITH target_books AS (
|
|
SELECT id FROM books WHERE {series_filter}
|
|
)
|
|
DELETE FROM book_reading_progress
|
|
WHERE book_id IN (SELECT id FROM target_books)
|
|
"#
|
|
)
|
|
} else {
|
|
format!(
|
|
r#"
|
|
INSERT INTO book_reading_progress (book_id, status, current_page, last_read_at, updated_at)
|
|
SELECT id, 'read', NULL, NOW(), NOW()
|
|
FROM books
|
|
WHERE {series_filter}
|
|
ON CONFLICT (book_id) DO UPDATE
|
|
SET status = 'read',
|
|
current_page = NULL,
|
|
last_read_at = NOW(),
|
|
updated_at = NOW()
|
|
"#
|
|
)
|
|
};
|
|
|
|
let result = if body.series == "unclassified" {
|
|
sqlx::query(&sql).execute(&state.pool).await?
|
|
} else {
|
|
sqlx::query(&sql).bind(&body.series).execute(&state.pool).await?
|
|
};
|
|
|
|
Ok(Json(MarkSeriesReadResponse {
|
|
updated: result.rows_affected() as i64,
|
|
}))
|
|
}
|