//! 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)) }