feat: implement thought thread retrieval with replies and visibility filtering

This commit is contained in:
2025-09-07 14:47:30 +02:00
parent b337184a59
commit 40695b7ad3
14 changed files with 244 additions and 55 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<AppState>,
Path(id): Path<Uuid>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
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<AppState> {
Router::new()
.route("/", post(thoughts_post))
.route("/{id}/thread", get(get_thought_thread))
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
}

View File

@@ -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))
}

View File

@@ -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<Uuid>,
) -> Result<Option<ThoughtThreadSchema>, 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<Uuid, ThoughtSchema>,
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
) -> Option<ThoughtThreadSchema> {
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<Uuid, ThoughtSchema> =
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
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))
}

View File

@@ -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);

View File

@@ -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" }

View File

@@ -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()))
}

View File

@@ -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
))

View File

@@ -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<Uuid>,
#[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<ThoughtSchema>,
}
@@ -70,3 +69,15 @@ impl From<ThoughtWithAuthor> 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<Uuid>,
pub created_at: DateTimeWithTimeZoneWrapper,
pub replies: Vec<ThoughtThreadSchema>,
}

View File

@@ -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<String>,
pub bio: Option<String>,
#[serde(rename = "avatarUrl")]
pub avatar_url: Option<String>,
#[serde(rename = "headerUrl")]
pub header_url: Option<String>,
#[serde(rename = "customCss")]
pub custom_css: Option<String>,
#[serde(rename = "topFriends")]
pub top_friends: Vec<String>,
#[serde(rename = "joinedAt")]
pub joined_at: DateTimeWithTimeZoneWrapper,
}
@@ -50,7 +45,7 @@ impl From<user::Model> 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<Vec<user::Model>> 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<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub top_friends: Vec<String>,
pub joined_at: DateTimeWithTimeZoneWrapper,
pub following: Vec<UserSchema>,
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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());
}