feat: read & upload history (0.19.0)
Per-user reading progress and uploader attribution. Schema (migration 0011): `read_progress` table (one row per (user, manga); chapter_id nullable on chapter delete) and nullable `uploaded_by` columns on mangas + chapters with partial indexes scoped to non-null rows. Endpoints (all `/me/*`, auth-scoped): - PUT `/v1/me/read-progress` upserts. FK violations + cross-manga chapter ids both surface as 4xx (404 / 422) so the API can't be used to write logically invalid rows. - GET `/v1/me/read-progress` paged newest-first list. - GET `/v1/me/read-progress/:manga_id` enriched with chapter_number for the manga page's Continue CTA. - DELETE `/v1/me/read-progress/:manga_id` idempotent. - GET `/v1/me/uploads` interleaved manga + chapter uploads as a tagged union; limit-only pagination. Existing manga + chapter upload handlers stamp `uploaded_by`. Frontend: - Reader emits progress on mount + page change (debounce) and via IntersectionObserver in continuous mode. High-water mark is seeded from the persisted server value so re-opening a chapter doesn't regress to page 1. Tab close survives via `sendBeacon` (fallback `keepalive` fetch); SPA navigation flushes via regular fetch. - Manga detail page shows "Continue reading Chapter N — page M" above the chapters list, working even for mangas with >50 chapters. - New `/profile/history` tab with reading history (clear-per-row, inline error on failure) and uploads (mangas + chapters mixed chronologically with type-aware rendering). 171 backend tests (incl. 16 history tests covering ownership, FK race, cross-link guard, chapter SET NULL behaviour) and 97 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,7 @@ async fn get_one(
|
||||
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<(StatusCode, Json<Chapter>)> {
|
||||
@@ -133,6 +133,7 @@ async fn create(
|
||||
manga_id,
|
||||
metadata.number,
|
||||
metadata.title.as_deref(),
|
||||
Some(user.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
145
backend/src/api/history.rs
Normal file
145
backend/src/api/history.rs
Normal file
@@ -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<AppState> {
|
||||
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<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(input): Json<UpsertReadProgress>,
|
||||
) -> AppResult<Json<ReadProgress>> {
|
||||
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<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> AppResult<Json<PagedResponse<ReadProgressSummary>>> {
|
||||
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<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
) -> AppResult<Json<ReadProgressForManga>> {
|
||||
// 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<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
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<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<UploadListParams>,
|
||||
) -> AppResult<Json<PagedResponse<UploadEntry>>> {
|
||||
// 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)))
|
||||
}
|
||||
@@ -169,6 +169,7 @@ async fn create(
|
||||
&status,
|
||||
metadata.description.as_deref(),
|
||||
&alt_titles,
|
||||
Some(_user.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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<AppState> {
|
||||
.merge(tags::routes())
|
||||
.merge(authors::routes())
|
||||
.merge(collections::routes())
|
||||
.merge(history::routes())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
backend/src/domain/read_progress.rs
Normal file
50
backend/src/domain/read_progress.rs
Normal file
@@ -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<Uuid>,
|
||||
pub page: i32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub chapter_id: Option<Uuid>,
|
||||
/// `None` when the chapter was deleted after this row was written
|
||||
/// (FK ON DELETE SET NULL on `chapter_id`).
|
||||
pub chapter_number: Option<i32>,
|
||||
pub page: i32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Uuid>,
|
||||
pub chapter_number: Option<i32>,
|
||||
pub page: i32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UpsertReadProgress {
|
||||
pub manga_id: Uuid,
|
||||
pub chapter_id: Option<Uuid>,
|
||||
pub page: Option<i32>,
|
||||
}
|
||||
40
backend/src/domain/upload_entry.rs
Normal file
40
backend/src/domain/upload_entry.rs
Normal file
@@ -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<Utc>,
|
||||
},
|
||||
Chapter {
|
||||
manga_id: Uuid,
|
||||
manga_title: String,
|
||||
manga_cover_image_path: Option<String>,
|
||||
chapter: Chapter,
|
||||
created_at: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Utc> {
|
||||
match self {
|
||||
UploadEntry::Manga { created_at, .. } => *created_at,
|
||||
UploadEntry::Chapter { created_at, .. } => *created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Uuid>,
|
||||
) -> AppResult<Chapter> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -181,17 +181,23 @@ pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult<MangaDetail> {
|
||||
/// 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<Uuid>,
|
||||
) -> AppResult<Manga> {
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
164
backend/src/repo/read_progress.rs
Normal file
164
backend/src/repo/read_progress.rs
Normal file
@@ -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<Uuid>,
|
||||
page: i32,
|
||||
) -> AppResult<ReadProgress> {
|
||||
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<ReadProgress> {
|
||||
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<ReadProgressForManga> {
|
||||
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<bool> {
|
||||
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<ReadProgressSummary>, 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(())
|
||||
}
|
||||
119
backend/src/repo/upload_history.rs
Normal file
119
backend/src/repo/upload_history.rs
Normal file
@@ -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<String>,
|
||||
chapter_id: Uuid,
|
||||
number: i32,
|
||||
title: Option<String>,
|
||||
page_count: i32,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 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<UploadEntry>, i64)> {
|
||||
let mangas: Vec<Manga> = 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<ChapterUploadRow> = 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<UploadEntry> = 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))
|
||||
}
|
||||
Reference in New Issue
Block a user