feat: suivi de la progression de lecture par livre
- API : nouvelle table book_reading_progress (migration 0016) et module reading_progress.rs avec GET/PATCH /books/:id/progress (token read) - API : GET /books/:id enrichi avec reading_status, reading_current_page, reading_last_read_at via LEFT JOIN - Backoffice : badge de statut (Non lu / En cours · p.N / Lu) sur la page de détail et overlay sur les BookCards - OpenSpec : change reading-progress avec proposal/design/specs/tasks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,11 @@ pub struct BookDetails {
|
||||
pub file_path: Option<String>,
|
||||
pub file_format: Option<String>,
|
||||
pub file_parse_status: Option<String>,
|
||||
/// Reading status: "unread", "reading", or "read"
|
||||
pub reading_status: String,
|
||||
pub reading_current_page: Option<i32>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub reading_last_read_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// List books with optional filtering and pagination
|
||||
@@ -189,7 +194,10 @@ pub async fn get_book(
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT b.id, b.library_id, b.kind, b.title, b.author, b.series, b.volume, b.language, b.page_count, b.thumbnail_path,
|
||||
bf.abs_path, bf.format, bf.parse_status
|
||||
bf.abs_path, bf.format, bf.parse_status,
|
||||
COALESCE(brp.status, 'unread') AS reading_status,
|
||||
brp.current_page AS reading_current_page,
|
||||
brp.last_read_at AS reading_last_read_at
|
||||
FROM books b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT abs_path, format, parse_status
|
||||
@@ -198,6 +206,7 @@ pub async fn get_book(
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
) bf ON TRUE
|
||||
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
|
||||
WHERE b.id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -221,6 +230,9 @@ pub async fn get_book(
|
||||
file_path: row.get("abs_path"),
|
||||
file_format: row.get("format"),
|
||||
file_parse_status: row.get("parse_status"),
|
||||
reading_status: row.get("reading_status"),
|
||||
reading_current_page: row.get("reading_current_page"),
|
||||
reading_last_read_at: row.get("reading_last_read_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unprocessable_entity(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod libraries;
|
||||
mod api_middleware;
|
||||
mod openapi;
|
||||
mod pages;
|
||||
mod reading_progress;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod state;
|
||||
@@ -106,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/books/:id", get(books::get_book))
|
||||
.route("/books/:id/thumbnail", get(books::get_thumbnail))
|
||||
.route("/books/:id/pages/:n", get(pages::get_page))
|
||||
.route("/books/:id/progress", get(reading_progress::get_reading_progress).patch(reading_progress::update_reading_progress))
|
||||
.route("/libraries/:library_id/series", get(books::list_series))
|
||||
.route("/search", get(search::search_books))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), api_middleware::read_rate_limit))
|
||||
|
||||
@@ -6,6 +6,8 @@ use utoipa::OpenApi;
|
||||
paths(
|
||||
crate::books::list_books,
|
||||
crate::books::get_book,
|
||||
crate::reading_progress::get_reading_progress,
|
||||
crate::reading_progress::update_reading_progress,
|
||||
crate::books::get_thumbnail,
|
||||
crate::books::list_series,
|
||||
crate::books::convert_book,
|
||||
@@ -42,6 +44,8 @@ use utoipa::OpenApi;
|
||||
crate::books::BookItem,
|
||||
crate::books::BooksPage,
|
||||
crate::books::BookDetails,
|
||||
crate::reading_progress::ReadingProgressResponse,
|
||||
crate::reading_progress::UpdateReadingProgressRequest,
|
||||
crate::books::SeriesItem,
|
||||
crate::books::SeriesPage,
|
||||
crate::pages::PageQuery,
|
||||
@@ -72,6 +76,7 @@ use utoipa::OpenApi;
|
||||
),
|
||||
tags(
|
||||
(name = "books", description = "Read-only endpoints for browsing and searching books"),
|
||||
(name = "reading-progress", description = "Reading progress tracking per book"),
|
||||
(name = "libraries", description = "Library management endpoints (Admin only)"),
|
||||
(name = "indexing", description = "Search index management and job control (Admin only)"),
|
||||
(name = "tokens", description = "API token management (Admin only)"),
|
||||
|
||||
167
apps/api/src/reading_progress.rs
Normal file
167
apps/api/src/reading_progress.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
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"),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user