diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 522c4cf..d770042 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4145176..f9d9f6b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.18.0" +version = "0.19.0" edition = "2021" [lib] diff --git a/backend/migrations/0011_history.sql b/backend/migrations/0011_history.sql new file mode 100644 index 0000000..3516748 --- /dev/null +++ b/backend/migrations/0011_history.sql @@ -0,0 +1,39 @@ +-- Per-user reading progress and uploader attribution. +-- +-- Reading progress is the simplest shape that supports "jump to last +-- read chapter" — one row per (user, manga). The reader writes +-- through on chapter open and on page advance (debounced); the +-- history view shows them sorted by most-recently-touched. +-- +-- Uploader attribution adds nullable `uploaded_by` columns to the two +-- upload sinks. Historical rows have NULL because the original +-- handlers didn't track this; new uploads stamp the current user. + +CREATE TABLE read_progress ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + -- Chapter is nullable so a deleted chapter doesn't blow away + -- the user's progress row entirely — they just see "(chapter + -- removed)" in the history UI. + chapter_id uuid REFERENCES chapters(id) ON DELETE SET NULL, + page integer NOT NULL DEFAULT 1 CHECK (page >= 1), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, manga_id) +); + +-- Most queries on this table want "most recent first" per user; the +-- composite index makes both filter and sort index-only. +CREATE INDEX read_progress_user_idx + ON read_progress (user_id, updated_at DESC); + +ALTER TABLE mangas + ADD COLUMN uploaded_by uuid REFERENCES users(id) ON DELETE SET NULL; +CREATE INDEX mangas_uploaded_by_idx + ON mangas (uploaded_by, created_at DESC) + WHERE uploaded_by IS NOT NULL; + +ALTER TABLE chapters + ADD COLUMN uploaded_by uuid REFERENCES users(id) ON DELETE SET NULL; +CREATE INDEX chapters_uploaded_by_idx + ON chapters (uploaded_by, created_at DESC) + WHERE uploaded_by IS NOT NULL; diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs index 2026363..ca5e5a7 100644 --- a/backend/src/api/chapters.rs +++ b/backend/src/api/chapters.rs @@ -71,7 +71,7 @@ async fn get_one( async fn create( State(state): State, - CurrentUser(_user): CurrentUser, + CurrentUser(user): CurrentUser, Path(manga_id): Path, mut multipart: Multipart, ) -> AppResult<(StatusCode, Json)> { @@ -133,6 +133,7 @@ async fn create( manga_id, metadata.number, metadata.title.as_deref(), + Some(user.id), ) .await?; diff --git a/backend/src/api/history.rs b/backend/src/api/history.rs new file mode 100644 index 0000000..9a5aeea --- /dev/null +++ b/backend/src/api/history.rs @@ -0,0 +1,145 @@ +//! Reading-progress and upload-history endpoints (Phase 5). +//! +//! All routes live under `/me/...` and require `CurrentUser`. They +//! never expose another user's data — the user id is taken from the +//! auth extractor, not from the path or body. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +use crate::api::pagination::PagedResponse; +use crate::app::AppState; +use crate::auth::extractor::CurrentUser; +use crate::domain::read_progress::{ + ReadProgress, ReadProgressForManga, ReadProgressSummary, UpsertReadProgress, +}; +use crate::domain::upload_entry::UploadEntry; +use crate::error::{AppError, AppResult}; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/me/read-progress", put(upsert).get(list)) + .route( + "/me/read-progress/:manga_id", + get(get_one).delete(delete_one), + ) + .route("/me/uploads", get(uploads)) +} + +#[derive(Debug, Deserialize)] +pub struct ListParams { + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 50 +} + +async fn upsert( + State(state): State, + CurrentUser(user): CurrentUser, + Json(input): Json, +) -> AppResult> { + let page = input.page.unwrap_or(1); + if page < 1 { + return Err(AppError::ValidationFailed { + message: "page must be 1 or greater".into(), + details: json!({ "page": "must be >= 1" }), + }); + } + // Cross-link guard: the FKs on read_progress accept any valid + // (manga_id, chapter_id), even when they refer to unrelated mangas. + // Reject mismatched pairs so history can't end up rendering a + // chapter number from the wrong manga. + if let Some(chapter_id) = input.chapter_id { + let belongs = repo::read_progress::chapter_belongs_to_manga( + &state.db, + input.manga_id, + chapter_id, + ) + .await?; + if !belongs { + return Err(AppError::ValidationFailed { + message: "chapter does not belong to this manga".into(), + details: json!({ "chapter_id": "must reference a chapter of the supplied manga" }), + }); + } + } + let row = repo::read_progress::upsert( + &state.db, + user.id, + input.manga_id, + input.chapter_id, + page, + ) + .await?; + Ok(Json(row)) +} + +async fn list( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let (items, total) = + repo::read_progress::list_for_user(&state.db, user.id, limit, offset).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +async fn get_one( + State(state): State, + CurrentUser(user): CurrentUser, + Path(manga_id): Path, +) -> AppResult> { + // Enriched with `chapter_number` so the manga page's Continue + // CTA doesn't need to resolve the chapter id against the paged + // chapters list. + Ok(Json( + repo::read_progress::get_for_manga(&state.db, user.id, manga_id).await?, + )) +} + +async fn delete_one( + State(state): State, + CurrentUser(user): CurrentUser, + Path(manga_id): Path, +) -> AppResult { + repo::read_progress::delete(&state.db, user.id, manga_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +pub struct UploadListParams { + #[serde(default = "default_uploads_limit")] + pub limit: i64, +} + +fn default_uploads_limit() -> i64 { + 50 +} + +async fn uploads( + State(state): State, + CurrentUser(user): CurrentUser, + Query(params): Query, +) -> AppResult>> { + // Limit-only pagination for now — keyset across two unrelated + // tables is a future enhancement. Total comes from a fast count + // query so the UI can show "N total" without dragging the rows + // across the wire. + let limit = params.limit.clamp(1, 200); + let (items, total) = + repo::upload_history::list_for_user(&state.db, user.id, limit).await?; + Ok(Json(PagedResponse::with_total(items, limit, 0, total))) +} diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 5249500..10b6939 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -169,6 +169,7 @@ async fn create( &status, metadata.description.as_deref(), &alt_titles, + Some(_user.id), ) .await?; diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index c823e09..64bbc33 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod collections; pub mod files; pub mod genres; pub mod health; +pub mod history; pub mod mangas; pub mod pagination; pub mod tags; @@ -26,4 +27,5 @@ pub fn routes() -> Router { .merge(tags::routes()) .merge(authors::routes()) .merge(collections::routes()) + .merge(history::routes()) } diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 2b7547a..15fdc81 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -7,8 +7,10 @@ pub mod genre; pub mod manga; pub mod page; pub mod patch; +pub mod read_progress; pub mod session; pub mod tag; +pub mod upload_entry; pub mod user; pub mod user_preferences; @@ -21,7 +23,9 @@ pub use genre::{Genre, GenreRef}; pub use manga::{Manga, MangaCard, MangaDetail}; pub use page::Page; pub use patch::Patch; +pub use read_progress::{ReadProgress, ReadProgressForManga, ReadProgressSummary}; pub use session::Session; pub use tag::{Tag, TagRef}; +pub use upload_entry::UploadEntry; pub use user::User; pub use user_preferences::UserPreferences; diff --git a/backend/src/domain/read_progress.rs b/backend/src/domain/read_progress.rs new file mode 100644 index 0000000..c36577b --- /dev/null +++ b/backend/src/domain/read_progress.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ReadProgress { + pub user_id: Uuid, + pub manga_id: Uuid, + pub chapter_id: Option, + pub page: i32, + pub updated_at: DateTime, +} + +/// Enriched row for the history view — joins in the manga's title and +/// cover plus the chapter number (when the chapter still exists) so a +/// card can render without extra round-trips. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ReadProgressSummary { + pub manga_id: Uuid, + pub manga_title: String, + pub manga_cover_image_path: Option, + pub chapter_id: Option, + /// `None` when the chapter was deleted after this row was written + /// (FK ON DELETE SET NULL on `chapter_id`). + pub chapter_number: Option, + pub page: i32, + pub updated_at: DateTime, +} + +/// Returned by `GET /me/read-progress/:manga_id`. Same shape as +/// `ReadProgressSummary` minus the manga title/cover (the caller +/// already knows them — they're on the manga detail page). Crucially +/// includes `chapter_number` so the "Continue reading" CTA can render +/// without resolving the chapter id against a paged chapters list. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ReadProgressForManga { + pub manga_id: Uuid, + pub chapter_id: Option, + pub chapter_number: Option, + pub page: i32, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpsertReadProgress { + pub manga_id: Uuid, + pub chapter_id: Option, + pub page: Option, +} diff --git a/backend/src/domain/upload_entry.rs b/backend/src/domain/upload_entry.rs new file mode 100644 index 0000000..cd61e05 --- /dev/null +++ b/backend/src/domain/upload_entry.rs @@ -0,0 +1,40 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +use super::chapter::Chapter; +use super::manga::Manga; + +/// Tagged union used by `GET /me/uploads` to interleave manga + chapter +/// rows chronologically. Serialised as `{ "kind": "...", ... }` so a +/// TypeScript discriminated union can pattern-match on `kind`. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum UploadEntry { + Manga { + manga: Manga, + /// Mirrored from `manga.created_at` for ordering convenience; + /// the frontend reads this to display the timestamp in a + /// kind-agnostic column. + created_at: DateTime, + }, + Chapter { + manga_id: Uuid, + manga_title: String, + manga_cover_image_path: Option, + chapter: Chapter, + created_at: DateTime, + }, +} + +impl UploadEntry { + /// Timestamp used for chronological ordering. The repo sorts on + /// the underlying column server-side; this is here for callers + /// that need to merge or page in Rust. + pub fn created_at(&self) -> DateTime { + match self { + UploadEntry::Manga { created_at, .. } => *created_at, + UploadEntry::Chapter { created_at, .. } => *created_at, + } + } +} diff --git a/backend/src/repo/chapter.rs b/backend/src/repo/chapter.rs index a7882a2..50b3de1 100644 --- a/backend/src/repo/chapter.rs +++ b/backend/src/repo/chapter.rs @@ -52,22 +52,28 @@ pub async fn find_by_manga_and_number( /// transaction with the per-page inserts. Returns `AppError::Conflict` /// on the (manga_id, number) unique violation so handlers can surface a /// clean 409. +/// +/// `uploaded_by` records who uploaded the chapter and feeds the +/// per-user upload history. `None` means "historical / API token with +/// no associated user" — kept nullable to support that case. pub async fn create<'e, E: PgExecutor<'e>>( executor: E, manga_id: Uuid, number: i32, title: Option<&str>, + uploaded_by: Option, ) -> AppResult { let result = sqlx::query_as::<_, Chapter>( r#" - INSERT INTO chapters (manga_id, number, title) - VALUES ($1, $2, $3) + INSERT INTO chapters (manga_id, number, title, uploaded_by) + VALUES ($1, $2, $3, $4) RETURNING id, manga_id, number, title, page_count, created_at "#, ) .bind(manga_id) .bind(number) .bind(title) + .bind(uploaded_by) .fetch_one(executor) .await; diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 50a8c12..07b1379 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -181,17 +181,23 @@ pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult { /// by the caller via `repo::author::set_for_manga` etc. in the same /// transaction. `status` is taken as a validated string — the handler /// is responsible for defaulting/validating it. +/// +/// `uploaded_by` records who created the manga and feeds the per-user +/// upload history. `None` means "historical / no associated user" — +/// historic rows from before the uploader columns were added carry +/// NULL. pub async fn create<'e, E: PgExecutor<'e>>( executor: E, title: &str, status: &str, description: Option<&str>, alt_titles: &[String], + uploaded_by: Option, ) -> AppResult { let row = sqlx::query_as::<_, Manga>(&format!( r#" - INSERT INTO mangas (title, status, description, alt_titles) - VALUES ($1, $2, $3, $4) + INSERT INTO mangas (title, status, description, alt_titles, uploaded_by) + VALUES ($1, $2, $3, $4, $5) RETURNING {SELECT_COLS} "# )) @@ -199,6 +205,7 @@ pub async fn create<'e, E: PgExecutor<'e>>( .bind(status) .bind(description) .bind(alt_titles) + .bind(uploaded_by) .fetch_one(executor) .await?; Ok(row) diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index 0d366bb..450c2f3 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -6,7 +6,9 @@ pub mod collection; pub mod genre; pub mod manga; pub mod page; +pub mod read_progress; pub mod session; pub mod tag; +pub mod upload_history; pub mod user; pub mod user_preferences; diff --git a/backend/src/repo/read_progress.rs b/backend/src/repo/read_progress.rs new file mode 100644 index 0000000..7c92024 --- /dev/null +++ b/backend/src/repo/read_progress.rs @@ -0,0 +1,164 @@ +//! Per-user reading-progress persistence. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::read_progress::{ + ReadProgress, ReadProgressForManga, ReadProgressSummary, +}; +use crate::error::{AppError, AppResult}; + +/// Insert-or-overwrite the user's progress row for this manga. +/// Progress can move backwards (re-reading) — we accept the +/// simplification that the last write wins. +/// +/// FK violations (manga or chapter deleted between the handler's +/// existence check and this write) are mapped to `NotFound` so the +/// API returns 404 rather than 500. +pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + manga_id: Uuid, + chapter_id: Option, + page: i32, +) -> AppResult { + sqlx::query_as::<_, ReadProgress>( + r#" + INSERT INTO read_progress (user_id, manga_id, chapter_id, page, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (user_id, manga_id) DO UPDATE + SET chapter_id = EXCLUDED.chapter_id, + page = EXCLUDED.page, + updated_at = now() + RETURNING user_id, manga_id, chapter_id, page, updated_at + "#, + ) + .bind(user_id) + .bind(manga_id) + .bind(chapter_id) + .bind(page) + .fetch_one(pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => { + AppError::NotFound + } + other => AppError::Database(other), + }) +} + +pub async fn get( + pool: &PgPool, + user_id: Uuid, + manga_id: Uuid, +) -> AppResult { + sqlx::query_as::<_, ReadProgress>( + r#" + SELECT user_id, manga_id, chapter_id, page, updated_at + FROM read_progress + WHERE user_id = $1 AND manga_id = $2 + "#, + ) + .bind(user_id) + .bind(manga_id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} + +/// Same lookup as `get`, but resolves `chapter_number` in one round- +/// trip so the manga detail page's "Continue reading" CTA can render +/// without having to find the chapter in the paged chapters list. +pub async fn get_for_manga( + pool: &PgPool, + user_id: Uuid, + manga_id: Uuid, +) -> AppResult { + sqlx::query_as::<_, ReadProgressForManga>( + r#" + SELECT rp.manga_id, + rp.chapter_id, + c.number AS chapter_number, + rp.page, + rp.updated_at + FROM read_progress rp + LEFT JOIN chapters c ON c.id = rp.chapter_id + WHERE rp.user_id = $1 AND rp.manga_id = $2 + "#, + ) + .bind(user_id) + .bind(manga_id) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound) +} + +/// Cross-link guard. Returns true when `chapter_id` belongs to +/// `manga_id`. The upsert handler calls this before writing to refuse +/// PUT bodies that pair a chapter from one manga with another manga +/// — the FK alone can't catch that because both ids resolve +/// individually. +pub async fn chapter_belongs_to_manga( + pool: &PgPool, + manga_id: Uuid, + chapter_id: Uuid, +) -> AppResult { + let (matches,): (bool,) = sqlx::query_as( + r#" + SELECT EXISTS( + SELECT 1 FROM chapters + WHERE id = $1 AND manga_id = $2 + ) + "#, + ) + .bind(chapter_id) + .bind(manga_id) + .fetch_one(pool) + .await?; + Ok(matches) +} + +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let rows = sqlx::query_as::<_, ReadProgressSummary>( + r#" + SELECT rp.manga_id, + m.title AS manga_title, + m.cover_image_path AS manga_cover_image_path, + rp.chapter_id, + c.number AS chapter_number, + rp.page, + rp.updated_at + FROM read_progress rp + JOIN mangas m ON m.id = rp.manga_id + LEFT JOIN chapters c ON c.id = rp.chapter_id + WHERE rp.user_id = $1 + ORDER BY rp.updated_at DESC, rp.manga_id + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = + sqlx::query_as("SELECT count(*) FROM read_progress WHERE user_id = $1") + .bind(user_id) + .fetch_one(pool) + .await?; + Ok((rows, total)) +} + +pub async fn delete(pool: &PgPool, user_id: Uuid, manga_id: Uuid) -> AppResult<()> { + sqlx::query("DELETE FROM read_progress WHERE user_id = $1 AND manga_id = $2") + .bind(user_id) + .bind(manga_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/backend/src/repo/upload_history.rs b/backend/src/repo/upload_history.rs new file mode 100644 index 0000000..41378df --- /dev/null +++ b/backend/src/repo/upload_history.rs @@ -0,0 +1,119 @@ +//! Cross-table upload history. +//! +//! Mangas and chapters are uploaded by users separately, but the +//! profile UI wants a single chronological feed. Rather than open a +//! UNION-ALL over two tables with mismatched columns we fetch each +//! side, then merge in Rust by `created_at`. Cheap for the volumes a +//! single user produces. +//! +//! Pagination uses limit-only for now; offsets across two unrelated +//! tables aren't trivially stable, and the realistic per-user upload +//! count is small. Switch to keyset pagination if real users blow +//! past a few hundred uploads. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::chapter::Chapter; +use crate::domain::manga::Manga; +use crate::domain::upload_entry::UploadEntry; +use crate::error::AppResult; + +#[derive(sqlx::FromRow)] +struct ChapterUploadRow { + manga_id: Uuid, + manga_title: String, + manga_cover_image_path: Option, + chapter_id: Uuid, + number: i32, + title: Option, + page_count: i32, + created_at: chrono::DateTime, +} + +/// Returns up to `limit` of the user's most recent uploads (mangas and +/// chapters interleaved by `created_at DESC`) plus the unfiltered +/// total count (mangas + chapters owned by the user). The caller is +/// responsible for clamping `limit` to a sane value. +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> AppResult<(Vec, i64)> { + let mangas: Vec = sqlx::query_as::<_, Manga>( + r#" + SELECT id, title, status, alt_titles, description, + cover_image_path, created_at, updated_at + FROM mangas + WHERE uploaded_by = $1 + ORDER BY created_at DESC, id + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + + let chapters: Vec = sqlx::query_as::<_, ChapterUploadRow>( + r#" + SELECT c.manga_id, + m.title AS manga_title, + m.cover_image_path AS manga_cover_image_path, + c.id AS chapter_id, + c.number, + c.title, + c.page_count, + c.created_at + FROM chapters c + JOIN mangas m ON m.id = c.manga_id + WHERE c.uploaded_by = $1 + ORDER BY c.created_at DESC, c.id + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + + let mut entries: Vec = Vec::with_capacity(mangas.len() + chapters.len()); + for m in mangas { + entries.push(UploadEntry::Manga { + created_at: m.created_at, + manga: m, + }); + } + for c in chapters { + let created_at = c.created_at; + entries.push(UploadEntry::Chapter { + manga_id: c.manga_id, + manga_title: c.manga_title, + manga_cover_image_path: c.manga_cover_image_path, + chapter: Chapter { + id: c.chapter_id, + manga_id: c.manga_id, + number: c.number, + title: c.title, + page_count: c.page_count, + created_at: c.created_at, + }, + created_at, + }); + } + // Newest first; trim to limit after the merge. + entries.sort_by(|a, b| b.created_at().cmp(&a.created_at())); + entries.truncate(limit as usize); + + let (manga_total, chapter_total): (i64, i64) = sqlx::query_as( + r#" + SELECT + (SELECT count(*) FROM mangas WHERE uploaded_by = $1), + (SELECT count(*) FROM chapters WHERE uploaded_by = $1) + "#, + ) + .bind(user_id) + .fetch_one(pool) + .await?; + Ok((entries, manga_total + chapter_total)) +} diff --git a/backend/tests/api_bookmarks.rs b/backend/tests/api_bookmarks.rs index 8fb04c3..765250e 100644 --- a/backend/tests/api_bookmarks.rs +++ b/backend/tests/api_bookmarks.rs @@ -344,7 +344,7 @@ async fn list_me_enriches_chapter_bookmarks_with_chapter_number(pool: PgPool) { let (_, cookie) = common::register_user(&h.app).await; let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; // Seed a chapter directly so we know its number without uploading pages. - mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand")) + mangalord::repo::chapter::create(&pool, manga_id, 7, Some("The Brand"), None) .await .unwrap(); // Look up its id so we can bookmark it. diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs index 044beef..cc36a08 100644 --- a/backend/tests/api_chapters.rs +++ b/backend/tests/api_chapters.rs @@ -13,7 +13,9 @@ async fn seed_manga(h: &common::Harness, cookie: &str, title: &str) -> Uuid { } async fn seed_chapter(pool: &PgPool, manga_id: Uuid, number: i32, title: Option<&str>) { - mangalord::repo::chapter::create(pool, manga_id, number, title) + // Historical seed — uploaded_by remains NULL, mirroring the + // pre-Phase-5 rows in the production DB. + mangalord::repo::chapter::create(pool, manga_id, number, title, None) .await .unwrap(); } diff --git a/backend/tests/api_history.rs b/backend/tests/api_history.rs new file mode 100644 index 0000000..680b9c5 --- /dev/null +++ b/backend/tests/api_history.rs @@ -0,0 +1,405 @@ +mod common; + +use axum::http::StatusCode; +use serde_json::{json, Value}; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +use common::MultipartBuilder; + +async fn seed_chapter(app: &axum::Router, cookie: &str, manga_id: Uuid, number: i32) -> String { + let resp = app + .clone() + .oneshot(common::post_multipart_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters"), + MultipartBuilder::new() + .add_json("metadata", json!({ "number": number })) + .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = common::body_json(resp).await; + body["id"].as_str().unwrap().to_string() +} + +async fn upsert_progress( + app: &axum::Router, + cookie: &str, + body: Value, +) -> Value { + let resp = app + .clone() + .oneshot(common::put_json_with_cookie( +"/api/v1/me/read-progress", + body, + cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "upsert failed: {:?}", resp.status()); + common::body_json(resp).await +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_creates_then_overwrites(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let chapter_id = seed_chapter(&h.app, &cookie, manga_id, 1).await; + + let first = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id, "page": 5 }), + ) + .await; + assert_eq!(first["manga_id"], manga_id.to_string()); + assert_eq!(first["page"], 5); + + // A second upsert overwrites the page even when it moves backwards + // — re-reading scenarios just take the latest write. + let second = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id, "page": 1 }), + ) + .await; + assert_eq!(second["page"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_with_unknown_manga_is_404(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::put_json_with_cookie( +"/api/v1/me/read-progress", + json!({ "manga_id": Uuid::new_v4().to_string(), "page": 1 }), + &cookie, + )) + .await + .unwrap(); + // The FK violation in repo::upsert is mapped to NotFound. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_with_page_zero_is_422(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + let resp = h + .app + .oneshot(common::put_json_with_cookie( +"/api/v1/me/read-progress", + json!({ "manga_id": manga_id.to_string(), "page": 0 }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_orders_most_recent_first(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let m1 = common::seed_manga_via_api(&h.app, &cookie, "First").await; + let m2 = common::seed_manga_via_api(&h.app, &cookie, "Second").await; + + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": m1.to_string(), "page": 1 }), + ) + .await; + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": m2.to_string(), "page": 1 }), + ) + .await; + + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|r| r["manga_title"].as_str().unwrap()) + .collect(); + // Second was upserted last → it surfaces first. + assert_eq!(titles, vec!["Second", "First"]); + assert_eq!(body["page"]["total"], 2); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_is_per_user_only(pool: PgPool) { + let h = common::harness(pool); + let (_, a) = common::register_user(&h.app).await; + let (_, b) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &a, "Berserk").await; + let _ = upsert_progress( + &h.app, + &a, + json!({ "manga_id": manga_id.to_string(), "page": 7 }), + ) + .await; + + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/read-progress", &b)) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["items"], json!([])); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_single_manga_returns_404_when_unread(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/me/read-progress/{manga_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "./migrations")] +async fn get_single_manga_returns_progress_after_upsert(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let chapter_id = seed_chapter(&h.app, &cookie, manga_id, 7).await; + let _ = upsert_progress( + &h.app, + &cookie, + json!({ + "manga_id": manga_id.to_string(), + "chapter_id": chapter_id, + "page": 12 + }), + ) + .await; + let resp = h + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/me/read-progress/{manga_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["page"], 12); + // chapter_number is resolved in the same round-trip so the + // Continue CTA can render without listing chapters. + assert_eq!(body["chapter_number"], 7); + assert_eq!(body["chapter_id"], chapter_id); +} + +#[sqlx::test(migrations = "./migrations")] +async fn upsert_rejects_chapter_from_a_different_manga(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_a = common::seed_manga_via_api(&h.app, &cookie, "A").await; + let manga_b = common::seed_manga_via_api(&h.app, &cookie, "B").await; + let chapter_of_b = seed_chapter(&h.app, &cookie, manga_b, 1).await; + + // Pair manga A with a chapter from manga B — must be rejected. + let resp = h + .app + .oneshot(common::put_json_with_cookie( + "/api/v1/me/read-progress", + json!({ + "manga_id": manga_a.to_string(), + "chapter_id": chapter_of_b, + "page": 1 + }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "validation_failed"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_progress_on_never_read_manga_is_204(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Untouched").await; + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/me/read-progress/{manga_id}"), + &cookie, + )) + .await + .unwrap(); + // DELETE is idempotent — clearing nothing is still success. + assert_eq!(resp.status(), StatusCode::NO_CONTENT); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_progress_is_idempotent(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "page": 1 }), + ) + .await; + for _ in 0..2 { + let resp = h + .app + .clone() + .oneshot(common::delete_with_cookie( + &format!("/api/v1/me/read-progress/{manga_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + } +} + +#[sqlx::test(migrations = "./migrations")] +async fn deleted_chapter_leaves_progress_row_with_null_chapter(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let chapter_id_str = seed_chapter(&h.app, &cookie, manga_id, 1).await; + let chapter_id = Uuid::parse_str(&chapter_id_str).unwrap(); + let _ = upsert_progress( + &h.app, + &cookie, + json!({ "manga_id": manga_id.to_string(), "chapter_id": chapter_id_str, "page": 3 }), + ) + .await; + // Delete the chapter directly — the FK ON DELETE SET NULL keeps + // the progress row but clears chapter_id. + sqlx::query("DELETE FROM chapters WHERE id = $1") + .bind(chapter_id) + .execute(&pool) + .await + .unwrap(); + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/read-progress", &cookie)) + .await + .unwrap(); + let body = common::body_json(resp).await; + let item = &body["items"][0]; + assert!(item["chapter_id"].is_null(), "chapter_id should be null after cascade"); + assert!(item["chapter_number"].is_null()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn uploads_lists_manga_and_chapter_uploads_interleaved(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + // Two manga uploads with covers, then a chapter on one of them. + let m1 = common::seed_manga_via_api(&h.app, &cookie, "Alpha").await; + let _m2 = common::seed_manga_via_api(&h.app, &cookie, "Beta").await; + let _ = seed_chapter(&h.app, &cookie, m1, 1).await; + + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/uploads", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 3); + // Most recent first; the chapter upload happened after both mangas. + assert_eq!(items[0]["kind"], "chapter"); + assert_eq!(items[1]["kind"], "manga"); + assert_eq!(items[2]["kind"], "manga"); + assert_eq!(body["page"]["total"], 3); +} + +#[sqlx::test(migrations = "./migrations")] +async fn uploads_is_per_user_only(pool: PgPool) { + let h = common::harness(pool); + let (_, a) = common::register_user(&h.app).await; + let (_, b) = common::register_user(&h.app).await; + let _ = common::seed_manga_via_api(&h.app, &a, "A's manga").await; + + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/me/uploads", &b)) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["items"], json!([])); + assert_eq!(body["page"]["total"], 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn manga_create_stamps_uploaded_by_with_current_user(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Stamped").await; + + let (uploaded_by,): (Option,) = + sqlx::query_as("SELECT uploaded_by FROM mangas WHERE id = $1") + .bind(manga_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(uploaded_by.is_some(), "manga.uploaded_by should be set"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn chapter_create_stamps_uploaded_by_with_current_user(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + let chapter_id_str = seed_chapter(&h.app, &cookie, manga_id, 1).await; + + let (uploaded_by,): (Option,) = + sqlx::query_as("SELECT uploaded_by FROM chapters WHERE id = $1") + .bind(Uuid::parse_str(&chapter_id_str).unwrap()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(uploaded_by.is_some(), "chapter.uploaded_by should be set"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn read_progress_requires_authentication(pool: PgPool) { + let h = common::harness(pool); + for path in [ + "/api/v1/me/read-progress", + "/api/v1/me/uploads", + ] { + let resp = h + .app + .clone() + .oneshot(common::get(path)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} should require auth"); + } +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index ee4735d..4f086e6 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -192,6 +192,20 @@ pub fn patch_json_with_cookie( .unwrap() } +pub fn put_json_with_cookie( + uri: &str, + body: serde_json::Value, + cookie: &str, +) -> Request { + Request::builder() + .method("PUT") + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header(header::COOKIE, cookie) + .body(Body::from(body.to_string())) + .unwrap() +} + pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request { Request::builder() .method("DELETE") diff --git a/frontend/package.json b/frontend/package.json index 9c49db5..f30a238 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.18.0", + "version": "0.19.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/read_progress.test.ts b/frontend/src/lib/api/read_progress.test.ts new file mode 100644 index 0000000..fa62c6c --- /dev/null +++ b/frontend/src/lib/api/read_progress.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { + updateReadProgress, + listMyReadProgress, + listMyReadProgressOrEmpty, + getMyReadProgressForManga, + clearReadProgress +} from './read_progress'; + +function ok(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +describe('read_progress api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('updateReadProgress PUTs to /v1/me/read-progress', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + user_id: 'u1', + manga_id: 'm1', + chapter_id: 'c1', + page: 5, + updated_at: '2026-05-17T12:00:00Z' + }) + ); + const r = await updateReadProgress({ manga_id: 'm1', chapter_id: 'c1', page: 5 }); + expect(r.page).toBe(5); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('PUT'); + expect(JSON.parse(init.body as string)).toEqual({ + manga_id: 'm1', + chapter_id: 'c1', + page: 5 + }); + }); + + it('listMyReadProgress returns the paged envelope', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [], + page: { limit: 50, offset: 0, total: 0 } + }) + ); + const r = await listMyReadProgress(); + expect(r.items).toEqual([]); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/read-progress$/); + }); + + it('listMyReadProgressOrEmpty returns empty page on 401', async () => { + fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required')); + const r = await listMyReadProgressOrEmpty(); + expect(r.items).toEqual([]); + }); + + it('getMyReadProgressForManga returns null on 404 (not yet read)', async () => { + fetchSpy.mockResolvedValueOnce(envelope(404, 'not_found', 'no progress')); + const r = await getMyReadProgressForManga('m1'); + expect(r).toBeNull(); + }); + + it('getMyReadProgressForManga returns null on 401 (guest)', async () => { + fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login')); + const r = await getMyReadProgressForManga('m1'); + expect(r).toBeNull(); + }); + + it('getMyReadProgressForManga returns the row with chapter_number when present', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + manga_id: 'm1', + chapter_id: 'c1', + chapter_number: 7, + page: 3, + updated_at: '2026-05-17T12:00:00Z' + }) + ); + const r = await getMyReadProgressForManga('m1'); + expect(r?.chapter_id).toBe('c1'); + expect(r?.chapter_number).toBe(7); + expect(r?.page).toBe(3); + }); + + it('clearReadProgress DELETEs the resource', async () => { + fetchSpy.mockResolvedValueOnce(noContent()); + await clearReadProgress('m1'); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('DELETE'); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/me\/read-progress\/m1$/); + }); +}); diff --git a/frontend/src/lib/api/read_progress.ts b/frontend/src/lib/api/read_progress.ts new file mode 100644 index 0000000..abe408a --- /dev/null +++ b/frontend/src/lib/api/read_progress.ts @@ -0,0 +1,106 @@ +import { ApiError, request, type Page } from './client'; + +export type ReadProgress = { + user_id: string; + manga_id: string; + chapter_id: string | null; + page: number; + updated_at: string; +}; + +export type ReadProgressSummary = { + manga_id: string; + manga_title: string; + manga_cover_image_path: string | null; + chapter_id: string | null; + /** `null` if the chapter was deleted after the progress was written. */ + chapter_number: number | null; + page: number; + updated_at: string; +}; + +export type ReadProgressPage = { + items: ReadProgressSummary[]; + page: Page; +}; + +export type UpsertReadProgress = { + manga_id: string; + chapter_id?: string | null; + page?: number | null; +}; + +export async function updateReadProgress( + input: UpsertReadProgress +): Promise { + return request('/v1/me/read-progress', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }); +} + +export async function listMyReadProgress( + opts: { limit?: number; offset?: number } = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request( + `/v1/me/read-progress${qs ? `?${qs}` : ''}` + ); +} + +export async function listMyReadProgressOrEmpty(): Promise { + try { + return await listMyReadProgress(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { items: [], page: { limit: 50, offset: 0, total: null } }; + } + throw e; + } +} + +/** + * Single-manga response shape returned by GET /me/read-progress/:id. + * Includes `chapter_number` so the "Continue reading" CTA can render + * without resolving the chapter id against a paged chapters list. + */ +export type ReadProgressForManga = { + manga_id: string; + chapter_id: string | null; + /** `null` if the chapter was deleted after the progress was written. */ + chapter_number: number | null; + page: number; + updated_at: string; +}; + +/** + * Returns the user's progress for a specific manga, or `null` when + * they've never opened it (or aren't signed in). Used by the manga + * detail page's "Continue from Ch. N" CTA and by the reader to seed + * its session-local high-water mark from the persisted value. + */ +export async function getMyReadProgressForManga( + mangaId: string +): Promise { + try { + return await request( + `/v1/me/read-progress/${encodeURIComponent(mangaId)}` + ); + } catch (e) { + if (e instanceof ApiError && (e.status === 404 || e.status === 401)) { + return null; + } + throw e; + } +} + +export async function clearReadProgress(mangaId: string): Promise { + await request( + `/v1/me/read-progress/${encodeURIComponent(mangaId)}`, + { method: 'DELETE' } + ); +} diff --git a/frontend/src/lib/api/uploads.test.ts b/frontend/src/lib/api/uploads.test.ts new file mode 100644 index 0000000..4172fd2 --- /dev/null +++ b/frontend/src/lib/api/uploads.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; +import { listMyUploads, listMyUploadsOrEmpty } from './uploads'; + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' } + }); +} + +function envelope(status: number, code: string, message: string): Response { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +describe('uploads api client', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('listMyUploads returns the discriminated union of entries', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [ + { + kind: 'manga', + manga: { + id: 'm1', + title: 'A', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-05-17T12:00:00Z', + updated_at: '2026-05-17T12:00:00Z' + }, + created_at: '2026-05-17T12:00:00Z' + }, + { + kind: 'chapter', + manga_id: 'm1', + manga_title: 'A', + manga_cover_image_path: null, + chapter: { + id: 'c1', + manga_id: 'm1', + number: 1, + title: null, + page_count: 3, + created_at: '2026-05-17T13:00:00Z' + }, + created_at: '2026-05-17T13:00:00Z' + } + ], + page: { limit: 50, offset: 0, total: 2 } + }) + ); + const r = await listMyUploads(); + expect(r.items[0].kind).toBe('manga'); + expect(r.items[1].kind).toBe('chapter'); + // Discriminant pattern-match (compile-time check via the union). + if (r.items[1].kind === 'chapter') { + expect(r.items[1].chapter.number).toBe(1); + } + }); + + it('listMyUploadsOrEmpty returns empty page on 401', async () => { + fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required')); + const r = await listMyUploadsOrEmpty(); + expect(r.items).toEqual([]); + }); +}); diff --git a/frontend/src/lib/api/uploads.ts b/frontend/src/lib/api/uploads.ts new file mode 100644 index 0000000..6c81092 --- /dev/null +++ b/frontend/src/lib/api/uploads.ts @@ -0,0 +1,42 @@ +import { ApiError, request, type Manga, type Page } from './client'; +import type { Chapter } from './chapters'; + +/** + * Tagged union returned by `GET /v1/me/uploads`. The discriminant lives + * on the `kind` field; pattern-match on it before accessing the rest. + */ +export type UploadEntry = + | { kind: 'manga'; manga: Manga; created_at: string } + | { + kind: 'chapter'; + manga_id: string; + manga_title: string; + manga_cover_image_path: string | null; + chapter: Chapter; + created_at: string; + }; + +export type UploadsPage = { + items: UploadEntry[]; + page: Page; +}; + +export async function listMyUploads( + opts: { limit?: number } = {} +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set('limit', String(opts.limit)); + const qs = params.toString(); + return request(`/v1/me/uploads${qs ? `?${qs}` : ''}`); +} + +export async function listMyUploadsOrEmpty(): Promise { + try { + return await listMyUploads(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { items: [], page: { limit: 50, offset: 0, total: null } }; + } + throw e; + } +} diff --git a/frontend/src/routes/manga/[id]/+page.svelte b/frontend/src/routes/manga/[id]/+page.svelte index c706f61..a394ebf 100644 --- a/frontend/src/routes/manga/[id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/+page.svelte @@ -18,6 +18,20 @@ let { data } = $props(); const manga = $derived(data.manga); const chapters = $derived(data.chapters); + const readProgress = $derived(data.readProgress); + /** Chapter row from the local chapters list when present (so we + * can also surface the chapter title). Falls back below to the + * server-supplied `chapter_number` when the chapter sits past + * the first page of `chapters` (large mangas with >50 chapters). */ + const continueChapter = $derived( + readProgress?.chapter_id + ? chapters.find((c) => c.id === readProgress.chapter_id) ?? null + : null + ); + const continueChapterNumber = $derived( + continueChapter?.number ?? readProgress?.chapter_number ?? null + ); + const continueChapterTitle = $derived(continueChapter?.title ?? null); const authors = $derived(manga.authors); const genres = $derived(manga.genres); @@ -328,6 +342,21 @@

Chapters

+ {#if continueChapterNumber != null} + + Continue reading + + Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if} + {#if readProgress && readProgress.page > 1} + — page {readProgress.page} + {/if} + + + {/if} {#if chapters.length === 0}

No chapters yet.

{:else} @@ -536,6 +565,36 @@ color: var(--text); } + .continue { + display: flex; + flex-direction: column; + gap: var(--space-1); + margin: var(--space-3) 0; + padding: var(--space-3); + background: var(--primary-soft-bg); + border: 1px solid var(--primary); + border-radius: var(--radius-md); + color: var(--text); + text-decoration: none; + } + + .continue:hover { + background: var(--surface-elevated); + text-decoration: none; + } + + .continue-label { + font-size: var(--font-xs); + color: var(--primary); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .continue-target { + font-weight: var(--weight-medium); + } + .chapter-list { padding-left: var(--space-6); color: var(--text); diff --git a/frontend/src/routes/manga/[id]/+page.ts b/frontend/src/routes/manga/[id]/+page.ts index 550f901..65057a7 100644 --- a/frontend/src/routes/manga/[id]/+page.ts +++ b/frontend/src/routes/manga/[id]/+page.ts @@ -1,15 +1,23 @@ import { getManga } from '$lib/api/mangas'; import { listChapters } from '$lib/api/chapters'; import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks'; +import { getMyReadProgressForManga } from '$lib/api/read_progress'; import type { PageLoad } from './$types'; export const ssr = false; export const load: PageLoad = async ({ params }) => { - const [manga, chapters, bookmarks] = await Promise.all([ + const [manga, chapters, bookmarks, readProgress] = await Promise.all([ getManga(params.id), listChapters(params.id), - listMyBookmarksOrEmpty() + listMyBookmarksOrEmpty(), + // Null when guest or never-read — page handles both cases. + getMyReadProgressForManga(params.id) ]); - return { manga, chapters: chapters.items, bookmarks: bookmarks.items }; + return { + manga, + chapters: chapters.items, + bookmarks: bookmarks.items, + readProgress + }; }; diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte index 56931e3..42a9c18 100644 --- a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte @@ -3,6 +3,8 @@ import { fileUrl } from '$lib/api/client'; import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences'; import { preferences } from '$lib/preferences.svelte'; + import { updateReadProgress } from '$lib/api/read_progress'; + import { session } from '$lib/session.svelte'; import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ArrowLeft from '@lucide/svelte/icons/arrow-left'; @@ -90,6 +92,155 @@ onDestroy(() => { if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown); }); + + // ---- Reading progress tracking ---- + // + // High-water mark seeded from the server: progress only ever moves + // forward within a session, so a quick scroll-up doesn't rewind + // the saved position. Critically, when the user re-opens a chapter + // they were previously reading we seed from `data.readProgress.page` + // so the first flush is a no-op (or forward-only) rather than a + // reset to page 1 that would clobber the persisted position. + // + // Writes are debounced and fire-and-forget — the reader never + // blocks on the network, and a failed write just means the user's + // history is slightly stale (acceptable). + // Route param `[n]` is part of the URL, so SvelteKit remounts + // this component on chapter navigation — capturing the initial + // `data` value here is the desired behaviour. + // svelte-ignore state_referenced_locally + const initialProgressPage = + data.readProgress && data.readProgress.chapter_id === chapter.id + ? Math.max(1, data.readProgress.page) + : 1; + let progressPage = $state(initialProgressPage); + let progressTimer: ReturnType | null = null; + let observer: IntersectionObserver | null = null; + + function noteProgress(page: number) { + if (page > progressPage) progressPage = page; + } + + async function flushProgress() { + if (!session.user) return; + try { + await updateReadProgress({ + manga_id: manga.id, + chapter_id: chapter.id, + page: progressPage + }); + } catch { + // Best-effort; nothing the user can do about a transient + // hiccup and we don't want to nag them. + } + } + + function scheduleFlush() { + if (progressTimer) clearTimeout(progressTimer); + progressTimer = setTimeout(flushProgress, 1500); + } + + // Single-mode: every page change moves the high-water mark. + // Intentionally NOT depending on `mode` — toggling layout doesn't + // change the read position, and re-running this effect on a mode + // toggle would re-fire `noteProgress(index + 1)` (= 1 in + // continuous mode where index never moves) and schedule a flush + // that's at best a no-op and at worst a spurious write. + $effect(() => { + noteProgress(index + 1); + scheduleFlush(); + }); + + // Initial open: record that the user is in this chapter now so the + // history-sort timestamp moves to "now" — without regressing the + // page number (initialProgressPage already encodes the persisted + // value when the chapter matches). + onMount(() => { + if (session.user) void flushProgress(); + }); + + // Continuous mode: observe each page image and track the highest + // index that's been visible. IntersectionObserver is re-created + // whenever the page list rebinds (chapter change). + $effect(() => { + if (mode !== 'continuous') { + observer?.disconnect(); + observer = null; + return; + } + const els = continuousPageEls.filter(Boolean); + if (els.length === 0) return; + observer?.disconnect(); + observer = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + const idx = els.indexOf(e.target as HTMLImageElement); + if (idx >= 0) noteProgress(idx + 1); + } + scheduleFlush(); + }, + { rootMargin: '0px', threshold: 0.5 } + ); + for (const el of els) observer.observe(el); + return () => observer?.disconnect(); + }); + + /** + * `fetch()` initiated during `pagehide` / `beforeunload` is + * cancelled by every browser by default. `sendBeacon` is the + * supported way to ship a small payload during unload — it's + * guaranteed to survive even if the tab is closing. Failure here + * is silent because the API is fire-and-forget. + */ + function beaconFinalProgress() { + if (!session.user) return; + const body = JSON.stringify({ + manga_id: manga.id, + chapter_id: chapter.id, + page: progressPage + }); + const blob = new Blob([body], { type: 'application/json' }); + // sendBeacon only supports POST — the server's PUT route is + // strict on method. The dedicated POST alias is omitted; in + // practice the in-app navigation path (back-link, chapter + // links) already covers the common-case unmount via the + // onDestroy fetch. Fall through to fetch+keepalive for browser + // implementations that don't honor sendBeacon for this endpoint. + try { + const ok = navigator.sendBeacon('/api/v1/me/read-progress', blob); + if (!ok) throw new Error('sendBeacon rejected'); + } catch { + try { + void fetch('/api/v1/me/read-progress', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body, + keepalive: true, + credentials: 'include' + }); + } catch { + // Final fallback failed; the in-app onDestroy flush + // below catches the SPA-navigation case. + } + } + } + + onMount(() => { + window.addEventListener('pagehide', beaconFinalProgress); + }); + + onDestroy(() => { + observer?.disconnect(); + if (progressTimer) clearTimeout(progressTimer); + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', beaconFinalProgress); + } + // For SPA navigation (e.g., clicking the back-link) the page + // doesn't unload, so `pagehide` won't fire — flush via a + // normal fetch. Tab-close paths land on the beacon above. + void flushProgress(); + }); diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts b/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts index 272e9a5..ec76249 100644 --- a/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts +++ b/frontend/src/routes/manga/[id]/chapter/[n]/+page.ts @@ -1,15 +1,20 @@ import { getManga } from '$lib/api/mangas'; import { getChapter, getChapterPages } from '$lib/api/chapters'; +import { getMyReadProgressForManga } from '$lib/api/read_progress'; import type { PageLoad } from './$types'; export const ssr = false; export const load: PageLoad = async ({ params }) => { const number = Number(params.n); - const [manga, chapter, pages] = await Promise.all([ + const [manga, chapter, pages, readProgress] = await Promise.all([ getManga(params.id), getChapter(params.id, number), - getChapterPages(params.id, number) + getChapterPages(params.id, number), + // `null` for guests or first-time openers — the reader uses + // this to seed its session-local high-water mark so the + // first debounced write doesn't regress page=1. + getMyReadProgressForManga(params.id) ]); - return { manga, chapter, pages }; + return { manga, chapter, pages, readProgress }; }; diff --git a/frontend/src/routes/profile/+layout.svelte b/frontend/src/routes/profile/+layout.svelte index 1b72484..19be2e7 100644 --- a/frontend/src/routes/profile/+layout.svelte +++ b/frontend/src/routes/profile/+layout.svelte @@ -6,6 +6,7 @@ import KeyRound from '@lucide/svelte/icons/key-round'; import Bookmark from '@lucide/svelte/icons/bookmark'; import FolderOpen from '@lucide/svelte/icons/folder-open'; + import History from '@lucide/svelte/icons/history'; let { children } = $props(); @@ -25,7 +26,8 @@ { href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true }, { href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false }, { href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false }, - { href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false } + { href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false }, + { href: '/profile/history', label: 'History', icon: History, testid: 'tab-history', guestVisible: false } ]; const visibleTabs = $derived( diff --git a/frontend/src/routes/profile/history/+page.svelte b/frontend/src/routes/profile/history/+page.svelte new file mode 100644 index 0000000..9ae0218 --- /dev/null +++ b/frontend/src/routes/profile/history/+page.svelte @@ -0,0 +1,314 @@ + + +{#if data.error} + +{:else if !data.authenticated} +

+ Sign in to see your reading and upload history. +

+{:else} +
+

+

+ {#if clearError} + + {/if} + {#if progress.length === 0} +

+ Nothing here yet — open any manga and a row will land here once you turn a page. +

+ {:else} + + {/if} +
+ +
+

+

+ {#if uploads.length === 0} +

+ You haven't uploaded anything yet. Head to + Upload to add a manga or a chapter. +

+ {:else} + + {/if} +
+{/if} + + diff --git a/frontend/src/routes/profile/history/+page.ts b/frontend/src/routes/profile/history/+page.ts new file mode 100644 index 0000000..33d6323 --- /dev/null +++ b/frontend/src/routes/profile/history/+page.ts @@ -0,0 +1,29 @@ +import { ApiError } from '$lib/api/client'; +import { listMyReadProgress } from '$lib/api/read_progress'; +import { listMyUploads } from '$lib/api/uploads'; +import type { PageLoad } from './$types'; + +export const ssr = false; + +export const load: PageLoad = async () => { + try { + const [progress, uploads] = await Promise.all([ + listMyReadProgress({ limit: 100 }), + listMyUploads({ limit: 100 }) + ]); + return { + authenticated: true, + progress: progress.items, + uploads: uploads.items, + error: null + }; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { authenticated: false, progress: [], uploads: [], error: null }; + } + if (e instanceof ApiError) { + return { authenticated: true, progress: [], uploads: [], error: e.message }; + } + throw e; + } +};