diff --git a/thoughts-backend/api/src/routers/feed.rs b/thoughts-backend/api/src/routers/feed.rs index cc3a9ad..39cfb03 100644 --- a/thoughts-backend/api/src/routers/feed.rs +++ b/thoughts-backend/api/src/routers/feed.rs @@ -1,18 +1,27 @@ -use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, + routing::get, + Json, Router, +}; use app::{ - persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self}, + persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self_paginated}, state::AppState, }; -use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; +use models::{ + queries::pagination::PaginationQuery, + schemas::{pagination::PaginatedResponse, thought::ThoughtSchema}, +}; use crate::{error::ApiError, extractor::AuthUser}; #[utoipa::path( get, path = "", + params(PaginationQuery), responses( - (status = 200, description = "Authenticated user's feed", body = ThoughtListSchema) + (status = 200, description = "Authenticated user's feed", body = PaginatedResponse) ), security( ("api_key" = []), @@ -22,17 +31,35 @@ use crate::{error::ApiError, extractor::AuthUser}; async fn feed_get( State(state): State, auth_user: AuthUser, + Query(pagination): Query, ) -> Result { let following_ids = get_following_ids(&state.conn, auth_user.id).await?; - let thoughts_with_authors = - get_feed_for_users_and_self(&state.conn, auth_user.id, following_ids).await?; + let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated( + &state.conn, + auth_user.id, + following_ids, + &pagination, + ) + .await?; let thoughts_schema: Vec = thoughts_with_authors .into_iter() .map(ThoughtSchema::from) .collect(); - Ok(Json(ThoughtListSchema::from(thoughts_schema))) + let page = pagination.page(); + let page_size = pagination.page_size(); + let total_pages = (total_items as f64 / page_size as f64).ceil() as u64; + + let response = PaginatedResponse { + items: thoughts_schema, + total_items, + total_pages, + page, + page_size, + }; + + Ok(Json(response)) } pub fn create_feed_router() -> Router { diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 9a2f8a8..0da44e2 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -1,12 +1,13 @@ use sea_orm::{ prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr, - EntityTrait, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, - TransactionTrait, + EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, + Set, TransactionTrait, }; use models::{ domains::{tag, thought, thought_tag, user}, params::thought::CreateThoughtParams, + queries::pagination::PaginationQuery, schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor}, }; @@ -187,6 +188,42 @@ pub async fn get_feed_for_users_and_self( .await } +pub async fn get_feed_for_users_and_self_paginated( + db: &DbConn, + user_id: Uuid, + following_ids: Vec, + pagination: &PaginationQuery, +) -> Result<(Vec, u64), DbErr> { + let mut authors_to_include = following_ids; + authors_to_include.push(user_id); + + let paginator = thought::Entity::find() + .select_only() + .column(thought::Column::Id) + .column(thought::Column::Content) + .column(thought::Column::ReplyToId) + .column(thought::Column::CreatedAt) + .column(thought::Column::Visibility) + .column(thought::Column::AuthorId) + .column_as(user::Column::Username, "author_username") + .column_as(user::Column::DisplayName, "author_display_name") + .join(JoinType::InnerJoin, thought::Relation::User.def()) + .filter(thought::Column::AuthorId.is_in(authors_to_include)) + .filter( + Condition::any() + .add(thought::Column::Visibility.eq(thought::Visibility::Public)) + .add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)), + ) + .order_by_desc(thought::Column::CreatedAt) + .into_model::() + .paginate(db, pagination.page_size()); + + let total_items = paginator.num_items().await?; + let thoughts = paginator.fetch_page(pagination.page() - 1).await?; + + Ok((thoughts, total_items)) +} + pub async fn get_thoughts_by_tag_name( db: &DbConn, tag_name: &str, diff --git a/thoughts-backend/tests/api/feed.rs b/thoughts-backend/tests/api/feed.rs index 0e81a13..d9a3e6e 100644 --- a/thoughts-backend/tests/api/feed.rs +++ b/thoughts-backend/tests/api/feed.rs @@ -3,7 +3,7 @@ use std::time::Duration; use super::main::{create_user_with_password, setup}; use axum::http::StatusCode; use http_body_util::BodyExt; -use serde_json::json; +use serde_json::{json, Value}; use tokio::time::sleep; use utils::testing::make_jwt_request; @@ -62,9 +62,9 @@ async fn test_feed_and_user_thoughts() { assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); - assert_eq!(v["thoughts"][0]["authorUsername"], "user1"); - assert_eq!(v["thoughts"][0]["content"], "A thought from user1"); + assert_eq!(v["items"].as_array().unwrap().len(), 1); + assert_eq!(v["items"][0]["authorUsername"], "user1"); + assert_eq!(v["items"][0]["content"], "A thought from user1"); // 3. user1 follows user2 make_jwt_request( @@ -81,11 +81,11 @@ async fn test_feed_and_user_thoughts() { assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["thoughts"].as_array().unwrap().len(), 2); - assert_eq!(v["thoughts"][0]["authorUsername"], "user2"); - assert_eq!(v["thoughts"][0]["content"], "user2 was here"); - assert_eq!(v["thoughts"][1]["authorUsername"], "user1"); - assert_eq!(v["thoughts"][1]["content"], "A thought from user1"); + assert_eq!(v["items"].as_array().unwrap().len(), 2); + assert_eq!(v["items"][0]["authorUsername"], "user2"); + assert_eq!(v["items"][0]["content"], "user2 was here"); + assert_eq!(v["items"][1]["authorUsername"], "user1"); + assert_eq!(v["items"][1]["content"], "A thought from user1"); } #[tokio::test] @@ -153,14 +153,121 @@ async fn test_feed_strict_chronological_order() { let body = response.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); - let thoughts = v["thoughts"].as_array().unwrap(); + let thoughts = v["items"].as_array().unwrap(); assert_eq!( thoughts.len(), 3, - "Feed should contain 3 thoughts from followed users" + "Feed should contain 3 thoughts from followed users and self" ); assert_eq!(thoughts[0]["content"], "Thought 3 from user2"); assert_eq!(thoughts[1]["content"], "Thought 2 from user3"); assert_eq!(thoughts[2]["content"], "Thought 1 from user2"); } + +#[tokio::test] +async fn test_feed_pagination() { + let app = setup().await; + + // 1. Setup users + create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await; + create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await; + create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await; + + let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await; + let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await; + let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await; + + // 2. user1 follows user2 and user3 + make_jwt_request( + app.router.clone(), + "/users/user2/follow", + "POST", + None, + &token1, + ) + .await; + make_jwt_request( + app.router.clone(), + "/users/user3/follow", + "POST", + None, + &token1, + ) + .await; + + // 3. Create 25 thoughts from the followed users to test pagination + // user1's feed also includes their own thoughts. + let mut last_thought_content = String::new(); + for i in 0..25 { + let content = format!("Thought number {}", i); + // Alternate who posts to mix up the feed + let token_to_use = match i % 3 { + 0 => &token2, + 1 => &token3, + _ => &token1, + }; + + let body = json!({ "content": &content }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(body), + token_to_use, + ) + .await; + + if i == 24 { + last_thought_content = content; + } + // Small delay to ensure created_at timestamps are distinct + sleep(Duration::from_millis(5)).await; + } + + // 4. Request the first page (default size 20) + let response_p1 = make_jwt_request( + app.router.clone(), + "/feed?page=1&page_size=20", + "GET", + None, + &token1, + ) + .await; + assert_eq!(response_p1.status(), StatusCode::OK); + let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes(); + let v_p1: Value = serde_json::from_slice(&body_p1).unwrap(); + + assert_eq!( + v_p1["items"].as_array().unwrap().len(), + 20, + "First page should have 20 items" + ); + assert_eq!(v_p1["page"], 1); + assert_eq!(v_p1["pageSize"], 20); + assert_eq!(v_p1["totalPages"], 2); + assert_eq!(v_p1["totalItems"], 25); + + // Verify the newest thought is first on the first page + assert_eq!(v_p1["items"][0]["content"], last_thought_content); + + // 5. Request the second page + let response_p2 = make_jwt_request( + app.router.clone(), + "/feed?page=2&page_size=20", + "GET", + None, + &token1, + ) + .await; + assert_eq!(response_p2.status(), StatusCode::OK); + let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes(); + let v_p2: Value = serde_json::from_slice(&body_p2).unwrap(); + + assert_eq!( + v_p2["items"].as_array().unwrap().len(), + 5, + "Second page should have the remaining 5 items" + ); + assert_eq!(v_p2["page"], 2); +} diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index 6345c3d..2f0e2fc 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -16,24 +16,44 @@ import { buildThoughtThreads } from "@/lib/utils"; import { TopFriends } from "@/components/top-friends"; import { UsersCount } from "@/components/users-count"; -export default async function Home() { +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +export default async function Home({ + searchParams, +}: { + searchParams: { page?: string }; +}) { const token = (await cookies()).get("auth_token")?.value ?? null; if (token) { - return ; + return ; } else { return ; } } -async function FeedPage({ token }: { token: string }) { +async function FeedPage({ + token, + searchParams, +}: { + token: string; + searchParams: { page?: string }; +}) { + const page = parseInt(searchParams.page ?? "1", 10); + const [feedData, me] = await Promise.all([ - getFeed(token), + getFeed(token, page), getMe(token).catch(() => null) as Promise, ]); - const allThoughts = feedData.thoughts; - const thoughtThreads = buildThoughtThreads(feedData.thoughts); + const { items: allThoughts, totalPages } = feedData; + const thoughtThreads = buildThoughtThreads(allThoughts); const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; const userProfiles = await Promise.all( @@ -84,6 +104,22 @@ async function FeedPage({ token }: { token: string }) {

)} + + + + 1 ? `/?page=${page - 1}` : "#"} + aria-disabled={page <= 1} + /> + + + = totalPages} + /> + + +