From 648d86970f60a60ce515c2cb4217b5cc6dce09bc Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Tue, 10 Mar 2026 21:53:52 +0100 Subject: [PATCH] feat: suivi de la progression de lecture par livre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/books.rs | 14 +- apps/api/src/error.rs | 7 + apps/api/src/main.rs | 2 + apps/api/src/openapi.rs | 5 + apps/api/src/reading_progress.rs | 167 ++++++++++++++++++ apps/backoffice/app/books/[id]/page.tsx | 44 ++++- apps/backoffice/app/components/BookCard.tsx | 34 +++- apps/backoffice/app/globals.css | 23 +++ apps/backoffice/lib/api.ts | 27 +++ .../migrations/0016_add_reading_progress.sql | 9 + .../changes/reading-progress/.openspec.yaml | 2 + openspec/changes/reading-progress/design.md | 66 +++++++ openspec/changes/reading-progress/proposal.md | 29 +++ .../specs/book-details/spec.md | 16 ++ .../specs/reading-progress/spec.md | 54 ++++++ openspec/changes/reading-progress/tasks.md | 28 +++ 16 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/reading_progress.rs create mode 100644 infra/migrations/0016_add_reading_progress.sql create mode 100644 openspec/changes/reading-progress/.openspec.yaml create mode 100644 openspec/changes/reading-progress/design.md create mode 100644 openspec/changes/reading-progress/proposal.md create mode 100644 openspec/changes/reading-progress/specs/book-details/spec.md create mode 100644 openspec/changes/reading-progress/specs/reading-progress/spec.md create mode 100644 openspec/changes/reading-progress/tasks.md diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index d781767..aa1b0d6 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -63,6 +63,11 @@ pub struct BookDetails { pub file_path: Option, pub file_format: Option, pub file_parse_status: Option, + /// Reading status: "unread", "reading", or "read" + pub reading_status: String, + pub reading_current_page: Option, + #[schema(value_type = Option)] + pub reading_last_read_at: Option>, } /// 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"), })) } diff --git a/apps/api/src/error.rs b/apps/api/src/error.rs index b60204d..3823588 100644 --- a/apps/api/src/error.rs +++ b/apps/api/src/error.rs @@ -38,6 +38,13 @@ impl ApiError { } } + pub fn unprocessable_entity(message: impl Into) -> Self { + Self { + status: StatusCode::UNPROCESSABLE_ENTITY, + message: message.into(), + } + } + pub fn not_found(message: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 53c1d57..2fdf673 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -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)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index d6d7657..6ec7115 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -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)"), diff --git a/apps/api/src/reading_progress.rs b/apps/api/src/reading_progress.rs new file mode 100644 index 0000000..89ddc69 --- /dev/null +++ b/apps/api/src/reading_progress.rs @@ -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, + #[schema(value_type = Option)] + pub last_read_at: Option>, +} + +#[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, +} + +/// 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, + Path(id): Path, +) -> Result, 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, + Path(id): Path, + Json(body): Json, +) -> Result, 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"), + })) +} diff --git a/apps/backoffice/app/books/[id]/page.tsx b/apps/backoffice/app/books/[id]/page.tsx index e849cf8..b004618 100644 --- a/apps/backoffice/app/books/[id]/page.tsx +++ b/apps/backoffice/app/books/[id]/page.tsx @@ -1,4 +1,4 @@ -import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch } from "../../../lib/api"; +import { fetchLibraries, getBookCoverUrl, BookDto, apiFetch, ReadingStatus } from "../../../lib/api"; import { BookPreview } from "../../components/BookPreview"; import { ConvertButton } from "../../components/ConvertButton"; import Image from "next/image"; @@ -7,6 +7,37 @@ import { notFound } from "next/navigation"; export const dynamic = "force-dynamic"; +const readingStatusConfig: Record = { + unread: { label: "Non lu", className: "bg-muted/60 text-muted-foreground border border-border" }, + reading: { label: "En cours", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30" }, + read: { label: "Lu", className: "bg-green-500/15 text-green-600 dark:text-green-400 border border-green-500/30" }, +}; + +function ReadingStatusBadge({ + status, + currentPage, + lastReadAt, +}: { + status: ReadingStatus; + currentPage: number | null; + lastReadAt: string | null; +}) { + const { label, className } = readingStatusConfig[status]; + return ( +
+ + {label} + {status === "reading" && currentPage != null && ` · p. ${currentPage}`} + + {lastReadAt && ( + + {new Date(lastReadAt).toLocaleDateString()} + + )} +
+ ); +} + async function fetchBook(bookId: string): Promise { try { return await apiFetch(`/books/${bookId}`); @@ -71,6 +102,17 @@ export default async function BookDetailPage({ )}
+ {book.reading_status && ( +
+ Lecture : + +
+ )} +
Format: = { + unread: null, + reading: { label: "En cours", className: "bg-amber-500/90 text-white" }, + read: { label: "Lu", className: "bg-green-600/90 text-white" }, +}; interface BookCardProps { book: BookDto & { coverUrl?: string }; + readingStatus?: ReadingStatus; } function BookImage({ src, alt }: { src: string; alt: string }) { @@ -37,18 +44,27 @@ function BookImage({ src, alt }: { src: string; alt: string }) { ); } -export function BookCard({ book }: BookCardProps) { +export function BookCard({ book, readingStatus }: BookCardProps) { const coverUrl = book.coverUrl || `/api/books/${book.id}/thumbnail`; - + const status = readingStatus ?? book.reading_status; + const overlay = status ? readingStatusOverlay[status] : null; + return ( - - +
+ + {overlay && ( + + {overlay.label} + + )} +
{/* Book Info */}
diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css index 447e33d..5dfd3bc 100644 --- a/apps/backoffice/app/globals.css +++ b/apps/backoffice/app/globals.css @@ -248,6 +248,29 @@ body::after { overflow: hidden; } +/* Reading progress badge variants */ +.badge-unread { + background: hsl(var(--color-muted) / 0.6); + color: hsl(var(--color-muted-foreground)); + border-color: hsl(var(--color-border)); +} + +.badge-in-progress { + background: hsl(38 92% 50% / 0.15); + color: hsl(38 92% 40%); + border-color: hsl(38 92% 50% / 0.3); +} + +.dark .badge-in-progress { + color: hsl(38 92% 65%); +} + +.badge-completed { + background: hsl(var(--color-success) / 0.15); + color: hsl(var(--color-success)); + border-color: hsl(var(--color-success) / 0.3); +} + /* Hide scrollbar */ .scrollbar-hide { -ms-overflow-style: none; diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 4b1e83d..497071a 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -46,6 +46,14 @@ export type FolderItem = { has_children: boolean; }; +export type ReadingStatus = "unread" | "reading" | "read"; + +export type ReadingProgressDto = { + status: ReadingStatus; + current_page: number | null; + last_read_at: string | null; +}; + export type BookDto = { id: string; library_id: string; @@ -60,6 +68,10 @@ export type BookDto = { file_format: string | null; file_parse_status: string | null; updated_at: string; + // Présents uniquement sur GET /books/:id (pas dans la liste) + reading_status?: ReadingStatus; + reading_current_page?: number | null; + reading_last_read_at?: string | null; }; export type BooksPageDto = { @@ -353,3 +365,18 @@ export async function getThumbnailStats() { export async function convertBook(bookId: string) { return apiFetch(`/books/${bookId}/convert`, { method: "POST" }); } + +export async function fetchReadingProgress(bookId: string) { + return apiFetch(`/books/${bookId}/progress`); +} + +export async function updateReadingProgress( + bookId: string, + status: ReadingStatus, + currentPage?: number, +) { + return apiFetch(`/books/${bookId}/progress`, { + method: "PATCH", + body: JSON.stringify({ status, current_page: currentPage ?? null }), + }); +} diff --git a/infra/migrations/0016_add_reading_progress.sql b/infra/migrations/0016_add_reading_progress.sql new file mode 100644 index 0000000..a707de9 --- /dev/null +++ b/infra/migrations/0016_add_reading_progress.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS book_reading_progress ( + book_id UUID PRIMARY KEY REFERENCES books(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('unread', 'reading', 'read')) DEFAULT 'unread', + current_page INT, + last_read_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_book_reading_progress_status ON book_reading_progress(status); diff --git a/openspec/changes/reading-progress/.openspec.yaml b/openspec/changes/reading-progress/.openspec.yaml new file mode 100644 index 0000000..f34a385 --- /dev/null +++ b/openspec/changes/reading-progress/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-10 diff --git a/openspec/changes/reading-progress/design.md b/openspec/changes/reading-progress/design.md new file mode 100644 index 0000000..5f23a9c --- /dev/null +++ b/openspec/changes/reading-progress/design.md @@ -0,0 +1,66 @@ +## Context + +L'API est une application axum single-user protégée par tokens (`admin` / `read`). Les données sont dans PostgreSQL, gérées via sqlx. Les routes existantes sont groupées en `admin_routes` et `read_routes` dans `main.rs`. La table `books` stocke uniquement les métadonnées de contenu (titre, auteur, pages, etc.) sans aucune notion d'état de lecture. + +Le `GET /books/:id` utilise déjà un `LEFT JOIN LATERAL` pour les `book_files` — le même pattern s'applique naturellement pour la progression de lecture. + +## Goals / Non-Goals + +**Goals:** +- Stocker et exposer l'état de lecture (unread / reading / read) par livre +- Mémoriser la page courante pour reprendre la lecture +- Horodater la dernière activité de lecture +- Enrichir `GET /books/:id` sans breaking change +- Documenter tous les endpoints dans Swagger via utoipa + +**Non-Goals:** +- Support multi-utilisateur (pas de concept d'utilisateur dans l'app) +- Historique des sessions de lecture +- Synchronisation entre clients +- Progression sur `GET /books` (liste paginée — impact perf non justifié) + +## Decisions + +### D1 — Table séparée `book_reading_progress` (vs colonnes sur `books`) + +`book_reading_progress` avec `book_id` comme PRIMARY KEY (relation 1-to-1). + +**Rationale** : sépare les métadonnées de contenu (immuables, issues de l'indexer) des données de lecture (mutables, issues de l'utilisateur). Facilite les requêtes ciblées et isole les permissions futures si multi-user. + +**Alternative rejetée** : colonnes directes sur `books` — plus simple mais mélange deux responsabilités différentes dans la même table. + +### D2 — Upsert sur PATCH (INSERT ... ON CONFLICT DO UPDATE) + +Une seule ligne par livre, créée à la première mise à jour. Pas de `created_at` dans la table. + +**Rationale** : évite un GET + INSERT/UPDATE en deux temps. La sémantique "créer si absent" est transparente pour le client. + +### D3 — Token `read` autorisé à écrire la progression + +Le PATCH est ajouté dans une section de `main.rs` protégée par `require_read` (pas `require_admin`). + +**Rationale** : le cas d'usage principal est une app de lecture tournant avec un token read-only. Exiger un token admin serait inutilement contraignant pour une opération purement personnelle. + +### D4 — Réponse par défaut si progression inexistante + +`GET /books/:id/progress` retourne `{ "status": "unread", "current_page": null, "last_read_at": null }` si aucune ligne n'existe, plutôt qu'un 404. + +**Rationale** : simplifie les clients — pas besoin de gérer un 404 comme état "non lu". L'absence de progression EST l'état "unread". + +### D5 — `current_page` : valeur positive sans validation contre `page_count` + +Accepter toute valeur `> 0` sans vérifier que la page existe dans le livre. + +**Rationale** : `page_count` peut être NULL (phase d'analyse pas encore terminée). La validation côté client est plus appropriée. + +## Risks / Trade-offs + +- **Pas de validation de page** → un client peut enregistrer `current_page = 9999` sur un livre de 10 pages. Risque faible dans un contexte single-user personnel. +- **Token read en écriture** → légère déviation du principe least-privilege. Acceptable car la progression n'affecte pas les autres données. +- **LEFT JOIN dans `GET /books/:id`** → requête légèrement plus lourde. Impact négligeable (1 row lookup sur clé primaire). + +## Migration Plan + +1. Déployer la migration `0016_add_reading_progress.sql` (non-destructive, nouvelle table) +2. Déployer le binaire API avec les nouveaux endpoints +3. Rollback : supprimer la table et redéployer l'ancienne version (aucune donnée critique perdue) diff --git a/openspec/changes/reading-progress/proposal.md b/openspec/changes/reading-progress/proposal.md new file mode 100644 index 0000000..533e8c6 --- /dev/null +++ b/openspec/changes/reading-progress/proposal.md @@ -0,0 +1,29 @@ +## Why + +L'application ne permet pas de suivre la progression de lecture d'un livre. Il est impossible de savoir quels livres ont été lus, lesquels sont en cours, ou de mémoriser la page courante pour reprendre là où on s'est arrêté. + +## What Changes + +- Nouvelle table PostgreSQL `book_reading_progress` pour stocker l'état de lecture par livre +- Nouvel endpoint `PATCH /books/:id/progress` pour mettre à jour la progression (accessible avec token `read` ou `admin`) +- Nouvel endpoint `GET /books/:id/progress` pour consulter la progression d'un livre +- Enrichissement de `GET /books/:id` avec les champs de progression (`reading_status`, `reading_current_page`, `reading_last_read_at`) +- Documentation Swagger complète via utoipa pour tous les nouveaux endpoints et schemas + +## Capabilities + +### New Capabilities + +- `reading-progress`: Suivi de l'état de lecture d'un livre (unread / reading / read), avec mémorisation de la page courante et horodatage de la dernière lecture + +### Modified Capabilities + +- `book-details`: Le détail d'un livre (`GET /books/:id`) expose désormais les informations de progression de lecture + +## Impact + +- **DB** : nouvelle migration `0016_add_reading_progress.sql` +- **API** : nouveau module `apps/api/src/reading_progress.rs` avec 2 handlers axum +- **API** : `apps/api/src/books.rs` — `BookDetails` enrichi + requête SQL modifiée (LEFT JOIN) +- **API** : `apps/api/src/main.rs` — ajout des routes dans `read_routes` (GET) et dans une section accessible au token `read` (PATCH) +- **API** : `apps/api/src/openapi.rs` — enregistrement des nouveaux composants Swagger diff --git a/openspec/changes/reading-progress/specs/book-details/spec.md b/openspec/changes/reading-progress/specs/book-details/spec.md new file mode 100644 index 0000000..a5bc96f --- /dev/null +++ b/openspec/changes/reading-progress/specs/book-details/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Consulter le détail d'un livre +Le système SHALL retourner les détails d'un livre via `GET /books/:id`, incluant désormais les informations de progression de lecture : `reading_status` (valeur par défaut `"unread"`), `reading_current_page` (nullable), et `reading_last_read_at` (nullable). + +#### Scenario: Livre sans progression enregistrée +- **WHEN** le client appelle `GET /books/:id` pour un livre sans progression +- **THEN** le système retourne HTTP 200 avec les champs de progression à leurs valeurs par défaut : `reading_status = "unread"`, `reading_current_page = null`, `reading_last_read_at = null` + +#### Scenario: Livre avec progression en cours +- **WHEN** le client appelle `GET /books/:id` pour un livre dont la progression est `reading` +- **THEN** le système retourne HTTP 200 avec `reading_status = "reading"`, `reading_current_page = `, `reading_last_read_at = ` + +#### Scenario: Livre inexistant +- **WHEN** le client appelle `GET /books/:id` avec un UUID inexistant +- **THEN** le système retourne HTTP 404 diff --git a/openspec/changes/reading-progress/specs/reading-progress/spec.md b/openspec/changes/reading-progress/specs/reading-progress/spec.md new file mode 100644 index 0000000..4a81b60 --- /dev/null +++ b/openspec/changes/reading-progress/specs/reading-progress/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Consulter la progression de lecture d'un livre +Le système SHALL retourner la progression de lecture d'un livre via `GET /books/:id/progress`. Si aucune progression n'a été enregistrée, le système SHALL retourner `{ "status": "unread", "current_page": null, "last_read_at": null }` sans erreur 404. + +#### Scenario: Progression inexistante +- **WHEN** le client appelle `GET /books/:id/progress` pour un livre sans progression enregistrée +- **THEN** le système retourne HTTP 200 avec `{ "status": "unread", "current_page": null, "last_read_at": null }` + +#### Scenario: Progression existante — livre en cours +- **WHEN** le client appelle `GET /books/:id/progress` pour un livre avec `status = "reading"` +- **THEN** le système retourne HTTP 200 avec `{ "status": "reading", "current_page": , "last_read_at": }` + +#### Scenario: Livre inexistant +- **WHEN** le client appelle `GET /books/:id/progress` avec un UUID invalide ou inexistant +- **THEN** le système retourne HTTP 404 + +### Requirement: Mettre à jour la progression de lecture +Le système SHALL permettre de mettre à jour la progression de lecture via `PATCH /books/:id/progress`. Cette route SHALL être accessible avec un token `read` ou `admin`. + +#### Scenario: Marquer un livre comme lu +- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "read" }` +- **THEN** le système enregistre `status = "read"`, `current_page = null`, `last_read_at = NOW()` et retourne HTTP 200 avec la progression mise à jour + +#### Scenario: Marquer un livre comme en cours avec page courante +- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "reading", "current_page": 42 }` +- **THEN** le système enregistre `status = "reading"`, `current_page = 42`, `last_read_at = NOW()` et retourne HTTP 200 avec la progression mise à jour + +#### Scenario: Réinitialiser la progression +- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "unread" }` +- **THEN** le système enregistre `status = "unread"`, `current_page = null`, `last_read_at = NOW()` et retourne HTTP 200 avec la progression mise à jour + +#### Scenario: current_page manquant pour status reading +- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "reading" }` sans `current_page` +- **THEN** le système retourne HTTP 422 avec un message d'erreur + +#### Scenario: current_page invalide (zéro ou négatif) +- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "reading", "current_page": 0 }` +- **THEN** le système retourne HTTP 422 avec un message d'erreur + +#### Scenario: current_page ignoré pour status non-reading +- **WHEN** le client envoie `PATCH /books/:id/progress` avec `{ "status": "read", "current_page": 42 }` +- **THEN** le système enregistre `current_page = null` (la valeur fournie est ignorée) + +#### Scenario: Livre inexistant +- **WHEN** le client envoie `PATCH /books/:id/progress` avec un UUID de livre inexistant +- **THEN** le système retourne HTTP 404 + +### Requirement: last_read_at mis à jour à chaque modification +Le système SHALL mettre à jour `last_read_at` à l'horodatage courant à chaque appel `PATCH /books/:id/progress`, quel que soit le statut. + +#### Scenario: last_read_at actualisé sur tout changement +- **WHEN** le client envoie `PATCH /books/:id/progress` avec n'importe quel statut valide +- **THEN** `last_read_at` dans la réponse est égal à l'heure de la requête (NOW()) diff --git a/openspec/changes/reading-progress/tasks.md b/openspec/changes/reading-progress/tasks.md new file mode 100644 index 0000000..27a6c05 --- /dev/null +++ b/openspec/changes/reading-progress/tasks.md @@ -0,0 +1,28 @@ +## 1. Migration DB + +- [x] 1.1 Créer `infra/migrations/0016_add_reading_progress.sql` avec la table `book_reading_progress` (book_id PK, status TEXT CHECK, current_page INT nullable, last_read_at TIMESTAMPTZ nullable, updated_at TIMESTAMPTZ) +- [x] 1.2 Ajouter un index sur `book_reading_progress(status)` pour faciliter les futurs filtres + +## 2. Module reading_progress.rs + +- [x] 2.1 Créer `apps/api/src/reading_progress.rs` avec les structs `ReadingProgressResponse` et `UpdateReadingProgressRequest` (avec ToSchema pour Swagger) +- [x] 2.2 Implémenter le handler `get_reading_progress` : vérifier que le livre existe (404 si non), LEFT JOIN sur `book_reading_progress`, retourner la valeur par défaut `unread` si absent +- [x] 2.3 Implémenter le handler `update_reading_progress` : vérifier que le livre existe (404 si non), valider `current_page > 0` si `status = "reading"` (422 sinon), upsert via `INSERT ... ON CONFLICT DO UPDATE` +- [x] 2.4 Ajouter les annotations `#[utoipa::path(...)]` sur les deux handlers (tag "reading-progress", params, request body, responses 200/404/422/401, security Bearer) + +## 3. Enrichissement GET /books/:id + +- [x] 3.1 Dans `apps/api/src/books.rs`, ajouter les champs `reading_status`, `reading_current_page`, `reading_last_read_at` à la struct `BookDetails` (avec ToSchema) +- [x] 3.2 Modifier la requête SQL de `get_book` pour inclure un `LEFT JOIN book_reading_progress brp ON brp.book_id = b.id` et mapper les champs (valeur par défaut `"unread"` via COALESCE) + +## 4. Routes dans main.rs + +- [x] 4.1 Déclarer le module `reading_progress` dans `apps/api/src/main.rs` +- [x] 4.2 Ajouter `GET /books/:id/progress` dans `read_routes` +- [x] 4.3 Ajouter `PATCH /books/:id/progress` dans `read_routes` (accessible token read et admin) + +## 5. Swagger / OpenAPI + +- [x] 5.1 Dans `apps/api/src/openapi.rs`, enregistrer `ReadingProgressResponse`, `UpdateReadingProgressRequest` dans les `components(schemas(...))` +- [x] 5.2 Enregistrer les paths `get_reading_progress` et `update_reading_progress` dans `paths(...)` +- [x] 5.3 Vérifier que `BookDetails` mis à jour est correctement reflété dans le Swagger généré