feat: implement pagination for feed retrieval and update frontend components
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m7s

This commit is contained in:
2025-09-09 03:43:06 +02:00
parent 4ea4f3149f
commit 64806f8bd4
5 changed files with 236 additions and 29 deletions

View File

@@ -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::{ 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, state::AppState,
}; };
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; use models::{
queries::pagination::PaginationQuery,
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
};
use crate::{error::ApiError, extractor::AuthUser}; use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path( #[utoipa::path(
get, get,
path = "", path = "",
params(PaginationQuery),
responses( responses(
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema) (status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
), ),
security( security(
("api_key" = []), ("api_key" = []),
@@ -22,17 +31,35 @@ use crate::{error::ApiError, extractor::AuthUser};
async fn feed_get( async fn feed_get(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let following_ids = get_following_ids(&state.conn, auth_user.id).await?; let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
let thoughts_with_authors = let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
get_feed_for_users_and_self(&state.conn, auth_user.id, following_ids).await?; &state.conn,
auth_user.id,
following_ids,
&pagination,
)
.await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter() .into_iter()
.map(ThoughtSchema::from) .map(ThoughtSchema::from)
.collect(); .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<AppState> { pub fn create_feed_router() -> Router<AppState> {

View File

@@ -1,12 +1,13 @@
use sea_orm::{ use sea_orm::{
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr, prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
EntityTrait, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
TransactionTrait, Set, TransactionTrait,
}; };
use models::{ use models::{
domains::{tag, thought, thought_tag, user}, domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams, params::thought::CreateThoughtParams,
queries::pagination::PaginationQuery,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor}, schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
}; };
@@ -187,6 +188,42 @@ pub async fn get_feed_for_users_and_self(
.await .await
} }
pub async fn get_feed_for_users_and_self_paginated(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
pagination: &PaginationQuery,
) -> Result<(Vec<ThoughtWithAuthor>, 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::<ThoughtWithAuthor>()
.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( pub async fn get_thoughts_by_tag_name(
db: &DbConn, db: &DbConn,
tag_name: &str, tag_name: &str,

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use super::main::{create_user_with_password, setup}; use super::main::{create_user_with_password, setup};
use axum::http::StatusCode; use axum::http::StatusCode;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::json; use serde_json::{json, Value};
use tokio::time::sleep; use tokio::time::sleep;
use utils::testing::make_jwt_request; use utils::testing::make_jwt_request;
@@ -62,9 +62,9 @@ async fn test_feed_and_user_thoughts() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); assert_eq!(v["items"].as_array().unwrap().len(), 1);
assert_eq!(v["thoughts"][0]["authorUsername"], "user1"); assert_eq!(v["items"][0]["authorUsername"], "user1");
assert_eq!(v["thoughts"][0]["content"], "A thought from user1"); assert_eq!(v["items"][0]["content"], "A thought from user1");
// 3. user1 follows user2 // 3. user1 follows user2
make_jwt_request( make_jwt_request(
@@ -81,11 +81,11 @@ async fn test_feed_and_user_thoughts() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2); assert_eq!(v["items"].as_array().unwrap().len(), 2);
assert_eq!(v["thoughts"][0]["authorUsername"], "user2"); assert_eq!(v["items"][0]["authorUsername"], "user2");
assert_eq!(v["thoughts"][0]["content"], "user2 was here"); assert_eq!(v["items"][0]["content"], "user2 was here");
assert_eq!(v["thoughts"][1]["authorUsername"], "user1"); assert_eq!(v["items"][1]["authorUsername"], "user1");
assert_eq!(v["thoughts"][1]["content"], "A thought from user1"); assert_eq!(v["items"][1]["content"], "A thought from user1");
} }
#[tokio::test] #[tokio::test]
@@ -153,14 +153,121 @@ async fn test_feed_strict_chronological_order() {
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); 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!( assert_eq!(
thoughts.len(), thoughts.len(),
3, 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[0]["content"], "Thought 3 from user2");
assert_eq!(thoughts[1]["content"], "Thought 2 from user3"); assert_eq!(thoughts[1]["content"], "Thought 2 from user3");
assert_eq!(thoughts[2]["content"], "Thought 1 from user2"); 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);
}

View File

@@ -16,24 +16,44 @@ import { buildThoughtThreads } from "@/lib/utils";
import { TopFriends } from "@/components/top-friends"; import { TopFriends } from "@/components/top-friends";
import { UsersCount } from "@/components/users-count"; 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; const token = (await cookies()).get("auth_token")?.value ?? null;
if (token) { if (token) {
return <FeedPage token={token} />; return <FeedPage token={token} searchParams={searchParams} />;
} else { } else {
return <LandingPage />; return <LandingPage />;
} }
} }
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([ const [feedData, me] = await Promise.all([
getFeed(token), getFeed(token, page),
getMe(token).catch(() => null) as Promise<Me | null>, getMe(token).catch(() => null) as Promise<Me | null>,
]); ]);
const allThoughts = feedData.thoughts; const { items: allThoughts, totalPages } = feedData;
const thoughtThreads = buildThoughtThreads(feedData.thoughts); const thoughtThreads = buildThoughtThreads(allThoughts);
const authors = [...new Set(allThoughts.map((t) => t.authorUsername))]; const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
const userProfiles = await Promise.all( const userProfiles = await Promise.all(
@@ -84,6 +104,22 @@ async function FeedPage({ token }: { token: string }) {
</p> </p>
)} )}
</div> </div>
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={page > 1 ? `/?page=${page - 1}` : "#"}
aria-disabled={page <= 1}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
href={page < totalPages ? `/?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</main> </main>
<aside className="hidden lg:block lg:col-span-1"> <aside className="hidden lg:block lg:col-span-1">

View File

@@ -168,11 +168,11 @@ export const loginUser = (data: z.infer<typeof LoginSchema>) =>
body: JSON.stringify(data), body: JSON.stringify(data),
}, z.object({ token: z.string() })); }, z.object({ token: z.string() }));
export const getFeed = (token: string) => export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
apiFetch( apiFetch(
"/feed", `/feed?page=${page}&page_size=${pageSize}`,
{}, {},
z.object({ thoughts: z.array(ThoughtSchema) }), z.object({ items: z.array(ThoughtSchema), totalPages: z.number() }),
token token
); );