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:
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