From 40695b7ad3210ee5460cda0b5ca8f27a775d1bf7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 7 Sep 2025 14:47:30 +0200 Subject: [PATCH] feat: implement thought thread retrieval with replies and visibility filtering --- thoughts-backend/Cargo.lock | 2 +- thoughts-backend/api/src/init.rs | 1 - thoughts-backend/api/src/routers/thought.rs | 30 +++++- thoughts-backend/api/src/routers/user.rs | 11 ++- .../app/src/persistence/thought.rs | 96 ++++++++++++++++++- thoughts-backend/common/src/lib.rs | 2 +- thoughts-backend/doc/Cargo.toml | 3 +- thoughts-backend/doc/src/lib.rs | 26 +---- thoughts-backend/doc/src/thought.rs | 8 +- .../models/src/schemas/thought.rs | 19 +++- thoughts-backend/models/src/schemas/user.rs | 21 ++-- thoughts-backend/src/shuttle.rs | 2 +- thoughts-backend/src/tokio.rs | 8 +- thoughts-backend/tests/api/thought.rs | 70 ++++++++++++++ 14 files changed, 244 insertions(+), 55 deletions(-) diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index 22d5602..3851e0d 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -1273,9 +1273,9 @@ dependencies = [ name = "doc" version = "0.1.0" dependencies = [ - "api", "axum 0.8.4", "models", + "tracing", "utoipa", "utoipa-scalar", "utoipa-swagger-ui", diff --git a/thoughts-backend/api/src/init.rs b/thoughts-backend/api/src/init.rs index 4b0184d..a74c69a 100644 --- a/thoughts-backend/api/src/init.rs +++ b/thoughts-backend/api/src/init.rs @@ -8,7 +8,6 @@ use app::state::AppState; use crate::routers::create_router; -// TODO: middleware, logging, authentication pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router { create_router(AppState { conn, diff --git a/thoughts-backend/api/src/routers/thought.rs b/thoughts-backend/api/src/routers/thought.rs index f428775..31a4cd7 100644 --- a/thoughts-backend/api/src/routers/thought.rs +++ b/thoughts-backend/api/src/routers/thought.rs @@ -11,7 +11,10 @@ use app::{ persistence::thought::{create_thought, delete_thought, get_thought}, state::AppState, }; -use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema}; +use models::{ + params::thought::CreateThoughtParams, + schemas::thought::{ThoughtSchema, ThoughtThreadSchema}, +}; use sea_orm::prelude::Uuid; use crate::{ @@ -118,8 +121,33 @@ async fn thoughts_delete( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + get, + path = "/{id}/thread", + params( + ("id" = Uuid, Path, description = "Thought ID") + ), + responses( + (status = 200, description = "Thought thread found", body = ThoughtThreadSchema), + (status = 404, description = "Not Found", body = ApiErrorResponse) + ) +)] +async fn get_thought_thread( + State(state): State, + Path(id): Path, + viewer: OptionalAuthUser, +) -> Result { + let viewer_id = viewer.0.map(|u| u.id); + let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id) + .await? + .ok_or(UserError::NotFound)?; + + Ok(Json(thread)) +} + pub fn create_thought_router() -> Router { Router::new() .route("/", post(thoughts_post)) + .route("/{id}/thread", get(get_thought_thread)) .route("/{id}", get(get_thought_by_id).delete(thoughts_delete)) } diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 62caba0..0cf9e44 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -345,10 +345,17 @@ async fn get_me( let following = get_following(&state.conn, auth_user.id).await?; let response = MeSchema { - user: UserSchema::from((user, top_friends)), + id: user.id, + username: user.username, + display_name: user.display_name, + bio: user.bio, + avatar_url: user.avatar_url, + header_url: user.header_url, + custom_css: user.custom_css, + top_friends: top_friends.into_iter().map(|u| u.username).collect(), + joined_at: user.created_at.into(), following: following.into_iter().map(UserSchema::from).collect(), }; - Ok(axum::Json(response)) } diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 5ef17de..da63a12 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -7,7 +7,7 @@ use sea_orm::{ use models::{ domains::{tag, thought, thought_tag, user}, params::thought::CreateThoughtParams, - schemas::thought::ThoughtWithAuthor, + schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor}, }; use crate::{ @@ -210,17 +210,103 @@ pub fn apply_visibility_filter( Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public)); if let Some(viewer) = viewer_id { - // Viewers can see their own thoughts of any visibility if user_id == viewer { condition = condition .add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)) .add(thought::Column::Visibility.eq(thought::Visibility::Private)); - } - // If the thought's author is a friend of the viewer, they can see it - else if !friend_ids.is_empty() && friend_ids.contains(&user_id) { + } else if !friend_ids.is_empty() && friend_ids.contains(&user_id) { condition = condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)); } } condition.into() } + +pub async fn get_thought_with_replies( + db: &DbConn, + thought_id: Uuid, + viewer_id: Option, +) -> Result, DbErr> { + let root_thought = match get_thought(db, thought_id, viewer_id).await? { + Some(t) => t, + None => return Ok(None), + }; + + let mut all_thoughts_in_thread = vec![root_thought.clone()]; + let mut ids_to_fetch = vec![root_thought.id]; + let mut friend_ids = vec![]; + if let Some(viewer) = viewer_id { + friend_ids = follow::get_friend_ids(db, viewer).await?; + } + + while !ids_to_fetch.is_empty() { + let replies = thought::Entity::find() + .filter(thought::Column::ReplyToId.is_in(ids_to_fetch)) + .all(db) + .await?; + + if replies.is_empty() { + break; + } + + ids_to_fetch = replies.iter().map(|r| r.id).collect(); + all_thoughts_in_thread.extend(replies); + } + + let mut thought_schemas = vec![]; + for thought in all_thoughts_in_thread { + if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? { + let is_visible = match thought.visibility { + thought::Visibility::Public => true, + thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id), + thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| { + v == thought.author_id || friend_ids.contains(&thought.author_id) + }), + }; + + if is_visible { + thought_schemas.push(ThoughtSchema::from_models(&thought, &author)); + } + } + } + + fn build_thread( + thought_id: Uuid, + schemas_map: &std::collections::HashMap, + replies_map: &std::collections::HashMap>, + ) -> Option { + schemas_map.get(&thought_id).map(|thought_schema| { + let replies = replies_map + .get(&thought_id) + .unwrap_or(&vec![]) + .iter() + .filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map)) + .collect(); + + ThoughtThreadSchema { + id: thought_schema.id, + author_username: thought_schema.author_username.clone(), + content: thought_schema.content.clone(), + visibility: thought_schema.visibility.clone(), + reply_to_id: thought_schema.reply_to_id, + created_at: thought_schema.created_at.clone(), + replies, + } + }) + } + + let schemas_map: std::collections::HashMap = + thought_schemas.into_iter().map(|s| (s.id, s)).collect(); + + let mut replies_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for thought in schemas_map.values() { + if let Some(parent_id) = thought.reply_to_id { + if schemas_map.contains_key(&parent_id) { + replies_map.entry(parent_id).or_default().push(thought.id); + } + } + } + + Ok(build_thread(root_thought.id, &schemas_map, &replies_map)) +} diff --git a/thoughts-backend/common/src/lib.rs b/thoughts-backend/common/src/lib.rs index fd109fd..9a08c4a 100644 --- a/thoughts-backend/common/src/lib.rs +++ b/thoughts-backend/common/src/lib.rs @@ -5,7 +5,7 @@ use sea_query::ValueTypeErr; use serde::Serialize; use utoipa::ToSchema; -#[derive(Serialize, ToSchema, Debug)] +#[derive(Serialize, ToSchema, Debug, Clone)] #[schema(example = "2025-09-05T12:34:56Z")] pub struct DateTimeWithTimeZoneWrapper(String); diff --git a/thoughts-backend/doc/Cargo.toml b/thoughts-backend/doc/Cargo.toml index d76e21f..dd03f55 100644 --- a/thoughts-backend/doc/Cargo.toml +++ b/thoughts-backend/doc/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] axum = { workspace = true } +tracing = { workspace = true } utoipa = { workspace = true, features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = [ "axum", @@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [ "axum", ], default-features = false } -api = { path = "../api" } +# api = { path = "../api" } models = { path = "../models" } diff --git a/thoughts-backend/doc/src/lib.rs b/thoughts-backend/doc/src/lib.rs index 8342a2e..01ee9bc 100644 --- a/thoughts-backend/doc/src/lib.rs +++ b/thoughts-backend/doc/src/lib.rs @@ -6,28 +6,8 @@ use utoipa::{ use utoipa_scalar::{Scalar, Servable as ScalarServable}; use utoipa_swagger_ui::SwaggerUi; -mod api_key; -mod auth; -mod feed; -mod friends; -mod root; -mod search; -mod tag; -mod thought; -mod user; #[derive(OpenApi)] #[openapi( - nest( - (path = "/", api = root::RootApi), - (path = "/auth", api = auth::AuthApi), - (path = "/users", api = user::UserApi), - (path = "/users/me/api-keys", api = api_key::ApiKeyApi), - (path = "/thoughts", api = thought::ThoughtApi), - (path = "/feed", api = feed::FeedApi), - (path = "/tags", api = tag::TagApi), - (path = "/friends", api = friends::FriendsApi), - (path = "/search", api = search::SearchApi), - ), tags( (name = "root", description = "Root API"), (name = "auth", description = "Authentication API"), @@ -57,12 +37,14 @@ impl Modify for SecurityAddon { } } -pub trait ApiDoc { +pub trait ApiDocExt { fn attach_doc(self) -> Self; } -impl ApiDoc for Router { +impl ApiDocExt for Router { fn attach_doc(self) -> Self { + tracing::info!("Attaching API documentation"); + self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi())) .merge(Scalar::with_url("/scalar", _ApiDoc::openapi())) } diff --git a/thoughts-backend/doc/src/thought.rs b/thoughts-backend/doc/src/thought.rs index 39a9c8c..6fa2ad6 100644 --- a/thoughts-backend/doc/src/thought.rs +++ b/thoughts-backend/doc/src/thought.rs @@ -2,15 +2,19 @@ use api::{ models::{ApiErrorResponse, ParamsErrorResponse}, routers::thought::*, }; -use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema}; +use models::{ + params::thought::CreateThoughtParams, + schemas::thought::{ThoughtSchema, ThoughtThreadSchema}, +}; use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(thoughts_post, thoughts_delete, get_thought_by_id), + paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread), components(schemas( CreateThoughtParams, ThoughtSchema, + ThoughtThreadSchema, ApiErrorResponse, ParamsErrorResponse )) diff --git a/thoughts-backend/models/src/schemas/thought.rs b/thoughts-backend/models/src/schemas/thought.rs index 0fbd516..f24d85f 100644 --- a/thoughts-backend/models/src/schemas/thought.rs +++ b/thoughts-backend/models/src/schemas/thought.rs @@ -8,18 +8,16 @@ use serde::Serialize; use utoipa::ToSchema; use uuid::Uuid; -#[derive(Serialize, ToSchema, FromQueryResult, Debug)] +#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)] +#[serde(rename_all = "camelCase")] pub struct ThoughtSchema { pub id: Uuid, #[schema(example = "frutiger")] - #[serde(rename = "authorUsername")] pub author_username: String, #[schema(example = "This is my first thought! #welcome")] pub content: String, pub visibility: Visibility, - #[serde(rename = "replyToId")] pub reply_to_id: Option, - #[serde(rename = "createdAt")] pub created_at: DateTimeWithTimeZoneWrapper, } @@ -37,6 +35,7 @@ impl ThoughtSchema { } #[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ThoughtListSchema { pub thoughts: Vec, } @@ -70,3 +69,15 @@ impl From for ThoughtSchema { } } } + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ThoughtThreadSchema { + pub id: Uuid, + pub author_username: String, + pub content: String, + pub visibility: Visibility, + pub reply_to_id: Option, + pub created_at: DateTimeWithTimeZoneWrapper, + pub replies: Vec, +} diff --git a/thoughts-backend/models/src/schemas/user.rs b/thoughts-backend/models/src/schemas/user.rs index d37fada..6560b72 100644 --- a/thoughts-backend/models/src/schemas/user.rs +++ b/thoughts-backend/models/src/schemas/user.rs @@ -6,21 +6,16 @@ use uuid::Uuid; use crate::domains::user; #[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct UserSchema { pub id: Uuid, pub username: String, - #[serde(rename = "displayName")] pub display_name: Option, pub bio: Option, - #[serde(rename = "avatarUrl")] pub avatar_url: Option, - #[serde(rename = "headerUrl")] pub header_url: Option, - #[serde(rename = "customCss")] pub custom_css: Option, - #[serde(rename = "topFriends")] pub top_friends: Vec, - #[serde(rename = "joinedAt")] pub joined_at: DateTimeWithTimeZoneWrapper, } @@ -50,7 +45,7 @@ impl From for UserSchema { avatar_url: user.avatar_url, header_url: user.header_url, custom_css: user.custom_css, - top_friends: vec![], // Defaults to an empty list + top_friends: vec![], joined_at: user.created_at.into(), } } @@ -70,8 +65,16 @@ impl From> for UserListSchema { } #[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct MeSchema { - #[serde(flatten)] - pub user: UserSchema, + pub id: Uuid, + pub username: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, + pub top_friends: Vec, + pub joined_at: DateTimeWithTimeZoneWrapper, pub following: Vec, } diff --git a/thoughts-backend/src/shuttle.rs b/thoughts-backend/src/shuttle.rs index 6935614..9d57b2b 100644 --- a/thoughts-backend/src/shuttle.rs +++ b/thoughts-backend/src/shuttle.rs @@ -1,5 +1,5 @@ use api::{setup_db, setup_router}; -use doc::ApiDoc; +use doc::ApiDocExt; use utils::migrate; pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum { diff --git a/thoughts-backend/src/tokio.rs b/thoughts-backend/src/tokio.rs index 2b6cddb..bcac908 100644 --- a/thoughts-backend/src/tokio.rs +++ b/thoughts-backend/src/tokio.rs @@ -1,10 +1,7 @@ use api::{setup_config, setup_db, setup_router}; -use doc::ApiDoc; use utils::{create_dev_db, migrate}; async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) { - tracing::info!("Worker {} started", child_num); - let conn = setup_db(db_url, prefork).await; if child_num == 0 { @@ -13,7 +10,7 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net: let config = setup_config(); - let router = setup_router(conn, &config).attach_doc(); + let router = setup_router(conn, &config); let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port"); axum::serve(listener, router).await.expect("start server"); @@ -44,11 +41,12 @@ fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) { } pub fn run() { + tracing::info!("Starting server..."); let config = setup_config(); let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port"); listener.set_nonblocking(true).expect("non blocking failed"); - println!("listening on http://{}", listener.local_addr().unwrap()); + tracing::info!("listening on http://{}", listener.local_addr().unwrap()); #[cfg(feature = "prefork")] if config.prefork { diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index b6b1626..36172c6 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -249,3 +249,73 @@ async fn test_get_thought_by_id_visibility() { "Friend should NOT see private thought" ); } + +#[tokio::test] +async fn test_get_thought_thread() { + let app = setup().await; + let _user1 = + create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; + let _user2 = + create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; + let user3 = + create_user_with_password(&app.db, "user3", "password123", "user3@example.com").await; + + let token1 = login_user(app.router.clone(), "user1", "password123").await; + let token2 = login_user(app.router.clone(), "user2", "password123").await; + + // 1. user1 posts a root thought + let root_id = post_thought_and_get_id(&app.router, "Root thought", "Public", &token1).await; + + // 2. user2 replies to the root thought + let reply1_body = json!({ "content": "First reply", "replyToId": root_id }).to_string(); + let response = make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(reply1_body), + &token2, + ) + .await; + let body = response.into_body().collect().await.unwrap().to_bytes(); + let reply1: Value = serde_json::from_slice(&body).unwrap(); + let reply1_id = reply1["id"].as_str().unwrap().to_string(); + + // 3. user1 replies to user2's reply + let reply2_body = + json!({ "content": "Reply to the reply", "replyToId": reply1_id }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(reply2_body), + &token1, + ) + .await; + + // 4. Fetch the entire thread + let response = make_get_request( + app.router.clone(), + &format!("/thoughts/{}/thread", root_id), + Some(user3.id), // Fetch as a third user to test visibility + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let thread: Value = serde_json::from_slice(&body).unwrap(); + + // 5. Assert the structure + assert_eq!(thread["content"], "Root thought"); + assert_eq!(thread["authorUsername"], "user1"); + assert_eq!(thread["replies"].as_array().unwrap().len(), 1); + + let reply_level_1 = &thread["replies"][0]; + assert_eq!(reply_level_1["content"], "First reply"); + assert_eq!(reply_level_1["authorUsername"], "user2"); + assert_eq!(reply_level_1["replies"].as_array().unwrap().len(), 1); + + let reply_level_2 = &reply_level_1["replies"][0]; + assert_eq!(reply_level_2["content"], "Reply to the reply"); + assert_eq!(reply_level_2["authorUsername"], "user1"); + assert!(reply_level_2["replies"].as_array().unwrap().is_empty()); +}