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>
120 lines
3.6 KiB
Rust
120 lines
3.6 KiB
Rust
//! 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))
|
|
}
|