From 64806f8bd4daa89b15fbefdc808427a3917cf08e Mon Sep 17 00:00:00 2001
From: Gabriel Kaszewski
Date: Tue, 9 Sep 2025 03:43:06 +0200
Subject: [PATCH] feat: implement pagination for feed retrieval and update
frontend components
---
thoughts-backend/api/src/routers/feed.rs | 41 +++++-
.../app/src/persistence/thought.rs | 41 +++++-
thoughts-backend/tests/api/feed.rs | 129 ++++++++++++++++--
thoughts-frontend/app/page.tsx | 48 ++++++-
thoughts-frontend/lib/api.ts | 6 +-
5 files changed, 236 insertions(+), 29 deletions(-)
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}
+ />
+
+
+