diff --git a/thoughts-backend/api/src/routers/mod.rs b/thoughts-backend/api/src/routers/mod.rs index b305f0a..ef77fa1 100644 --- a/thoughts-backend/api/src/routers/mod.rs +++ b/thoughts-backend/api/src/routers/mod.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod feed; pub mod friends; pub mod root; +pub mod search; pub mod tag; pub mod thought; pub mod user; @@ -30,6 +31,7 @@ pub fn create_router(state: AppState) -> Router { .nest("/feed", create_feed_router()) .nest("/tags", tag::create_tag_router()) .nest("/friends", friends::create_friends_router()) + .nest("/search", search::create_search_router()) .with_state(state) .layer(cors) } diff --git a/thoughts-backend/api/src/routers/search.rs b/thoughts-backend/api/src/routers/search.rs new file mode 100644 index 0000000..46fcc39 --- /dev/null +++ b/thoughts-backend/api/src/routers/search.rs @@ -0,0 +1,53 @@ +use crate::{error::ApiError, extractor::OptionalAuthUser}; +use app::{persistence::search, state::AppState}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, + routing::get, + Json, Router, +}; +use models::schemas::{ + search::SearchResultsSchema, + thought::{ThoughtListSchema, ThoughtSchema}, + user::UserListSchema, +}; +use serde::Deserialize; +use utoipa::IntoParams; + +#[derive(Deserialize, IntoParams)] +pub struct SearchQuery { + q: String, +} + +#[utoipa::path( + get, + path = "", + params(SearchQuery), + responses((status = 200, body = SearchResultsSchema)) +)] +async fn search_all( + State(state): State, + viewer: OptionalAuthUser, + Query(query): Query, +) -> Result { + let viewer_id = viewer.0.map(|u| u.id); + + let (users, thoughts) = tokio::try_join!( + search::search_users(&state.conn, &query.q), + search::search_thoughts(&state.conn, &query.q, viewer_id) + )?; + + let thought_schemas: Vec = + thoughts.into_iter().map(ThoughtSchema::from).collect(); + + let response = SearchResultsSchema { + users: UserListSchema::from(users), + thoughts: ThoughtListSchema::from(thought_schemas), + }; + + Ok(Json(response)) +} + +pub fn create_search_router() -> Router { + Router::new().route("/", get(search_all)) +} diff --git a/thoughts-backend/app/src/persistence/mod.rs b/thoughts-backend/app/src/persistence/mod.rs index d53e800..174ce76 100644 --- a/thoughts-backend/app/src/persistence/mod.rs +++ b/thoughts-backend/app/src/persistence/mod.rs @@ -1,6 +1,7 @@ pub mod api_key; pub mod auth; pub mod follow; +pub mod search; pub mod tag; pub mod thought; pub mod user; diff --git a/thoughts-backend/app/src/persistence/search.rs b/thoughts-backend/app/src/persistence/search.rs new file mode 100644 index 0000000..98e6247 --- /dev/null +++ b/thoughts-backend/app/src/persistence/search.rs @@ -0,0 +1,65 @@ +use models::{ + domains::{thought, user}, + schemas::thought::ThoughtWithAuthor, +}; +use sea_orm::{ + prelude::{Expr, Uuid}, + DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait, + Value, +}; + +use crate::persistence::follow; + +fn is_visible( + author_id: Uuid, + viewer_id: Option, + friend_ids: &[Uuid], + visibility: &thought::Visibility, +) -> bool { + match visibility { + thought::Visibility::Public => true, + thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id), + thought::Visibility::FriendsOnly => { + viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id)) + } + } +} + +pub async fn search_thoughts( + db: &DatabaseConnection, + query: &str, + viewer_id: Option, +) -> Result, DbErr> { + let mut friend_ids = Vec::new(); + if let Some(viewer) = viewer_id { + friend_ids = follow::get_friend_ids(db, viewer).await?; + } + + // We must join with the user table to get the author's username + let thoughts_with_authors = thought::Entity::find() + .column_as(user::Column::Username, "author_username") + .join(JoinType::InnerJoin, thought::Relation::User.def()) + .filter(Expr::cust_with_values( + "thought.search_document @@ websearch_to_tsquery('english', $1)", + [Value::from(query)], + )) + .into_model::() // Convert directly in the query + .all(db) + .await?; + + // Apply visibility filtering in Rust after the search + Ok(thoughts_with_authors + .into_iter() + .filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility)) + .collect()) +} + +pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result, DbErr> { + user::Entity::find() + .filter(Expr::cust_with_values( + "\"user\".search_document @@ websearch_to_tsquery('english', $1)", + [Value::from(query)], + )) + .all(db) + .await +} diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 5e60b07..5ef17de 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -201,7 +201,7 @@ pub async fn get_thoughts_by_tag_name( Ok(visible_thoughts) } -fn apply_visibility_filter( +pub fn apply_visibility_filter( user_id: Uuid, viewer_id: Option, friend_ids: &[Uuid], diff --git a/thoughts-backend/doc/src/lib.rs b/thoughts-backend/doc/src/lib.rs index 46234d4..8342a2e 100644 --- a/thoughts-backend/doc/src/lib.rs +++ b/thoughts-backend/doc/src/lib.rs @@ -11,10 +11,10 @@ mod auth; mod feed; mod friends; mod root; +mod search; mod tag; mod thought; mod user; - #[derive(OpenApi)] #[openapi( nest( @@ -26,6 +26,7 @@ mod user; (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"), @@ -35,6 +36,7 @@ mod user; (name = "feed", description = "Feed API"), (name = "tag", description = "Tag Discovery API"), (name = "friends", description = "Friends API"), + (name = "search", description = "Search API"), ), modifiers(&SecurityAddon), )] diff --git a/thoughts-backend/doc/src/search.rs b/thoughts-backend/doc/src/search.rs new file mode 100644 index 0000000..dca1dec --- /dev/null +++ b/thoughts-backend/doc/src/search.rs @@ -0,0 +1,21 @@ +use api::{models::ApiErrorResponse, routers::search::*}; +use models::schemas::{ + search::SearchResultsSchema, + thought::{ThoughtListSchema, ThoughtSchema}, + user::{UserListSchema, UserSchema}, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths(search_all), + components(schemas( + SearchResultsSchema, + ApiErrorResponse, + ThoughtSchema, + ThoughtListSchema, + UserSchema, + UserListSchema + )) +)] +pub(super) struct SearchApi; diff --git a/thoughts-backend/migration/src/lib.rs b/thoughts-backend/migration/src/lib.rs index 0d9eb93..29a68c8 100644 --- a/thoughts-backend/migration/src/lib.rs +++ b/thoughts-backend/migration/src/lib.rs @@ -7,6 +7,7 @@ mod m20250906_130237_add_tags; mod m20250906_134056_add_api_keys; mod m20250906_145148_add_reply_to_thoughts; mod m20250906_145755_add_visibility_to_thoughts; +mod m20250906_231359_add_full_text_search; pub struct Migrator; @@ -21,6 +22,7 @@ impl MigratorTrait for Migrator { Box::new(m20250906_134056_add_api_keys::Migration), Box::new(m20250906_145148_add_reply_to_thoughts::Migration), Box::new(m20250906_145755_add_visibility_to_thoughts::Migration), + Box::new(m20250906_231359_add_full_text_search::Migration), ] } } diff --git a/thoughts-backend/migration/src/m20250906_231359_add_full_text_search.rs b/thoughts-backend/migration/src/m20250906_231359_add_full_text_search.rs new file mode 100644 index 0000000..ce21b99 --- /dev/null +++ b/thoughts-backend/migration/src/m20250906_231359_add_full_text_search.rs @@ -0,0 +1,48 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // --- Users Table --- + // Add the tsvector column for users + manager.get_connection().execute_unprepared( + "ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \ + GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED" + ).await?; + // Add the GIN index for users + manager.get_connection().execute_unprepared( + "CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")" + ).await?; + + // --- Thoughts Table --- + // Add the tsvector column for thoughts + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \ + GENERATED ALWAYS AS (to_tsvector('english', content)) STORED", + ) + .await?; + // Add the GIN index for thoughts + manager.get_connection().execute_unprepared( + "CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")" + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"") + .await?; + manager + .get_connection() + .execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"") + .await?; + Ok(()) + } +} diff --git a/thoughts-backend/models/src/domains/thought.rs b/thoughts-backend/models/src/domains/thought.rs index cdae5b9..92f1640 100644 --- a/thoughts-backend/models/src/domains/thought.rs +++ b/thoughts-backend/models/src/domains/thought.rs @@ -25,6 +25,8 @@ pub struct Model { pub reply_to_id: Option, pub visibility: Visibility, pub created_at: DateTimeWithTimeZone, + #[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)] + pub search_document: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/thoughts-backend/models/src/domains/user.rs b/thoughts-backend/models/src/domains/user.rs index 4e0e828..973b908 100644 --- a/thoughts-backend/models/src/domains/user.rs +++ b/thoughts-backend/models/src/domains/user.rs @@ -19,6 +19,8 @@ pub struct Model { pub custom_css: Option, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, + #[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)] + pub search_document: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/thoughts-backend/models/src/schemas/mod.rs b/thoughts-backend/models/src/schemas/mod.rs index 77fb125..e0cfe5c 100644 --- a/thoughts-backend/models/src/schemas/mod.rs +++ b/thoughts-backend/models/src/schemas/mod.rs @@ -1,3 +1,4 @@ pub mod api_key; +pub mod search; pub mod thought; pub mod user; diff --git a/thoughts-backend/models/src/schemas/search.rs b/thoughts-backend/models/src/schemas/search.rs new file mode 100644 index 0000000..7bda3ff --- /dev/null +++ b/thoughts-backend/models/src/schemas/search.rs @@ -0,0 +1,9 @@ +use super::{thought::ThoughtListSchema, user::UserListSchema}; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct SearchResultsSchema { + pub users: UserListSchema, + pub thoughts: ThoughtListSchema, +} diff --git a/thoughts-backend/tests/api/mod.rs b/thoughts-backend/tests/api/mod.rs index a97e54a..9285b4a 100644 --- a/thoughts-backend/tests/api/mod.rs +++ b/thoughts-backend/tests/api/mod.rs @@ -4,6 +4,7 @@ mod auth; mod feed; mod follow; mod main; +mod search; mod tag; mod thought; mod user; diff --git a/thoughts-backend/tests/api/search.rs b/thoughts-backend/tests/api/search.rs new file mode 100644 index 0000000..c0ede48 --- /dev/null +++ b/thoughts-backend/tests/api/search.rs @@ -0,0 +1,198 @@ +use crate::api::main::{create_user_with_password, login_user, setup}; +use axum::http::StatusCode; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use utils::testing::{make_get_request, make_jwt_request}; + +#[tokio::test] +async fn test_search_all() { + let app = setup().await; + + // 1. Setup users and data + let user1 = + create_user_with_password(&app.db, "search_user1", "password123", "s1@test.com").await; + let user2 = + create_user_with_password(&app.db, "search_user2", "password123", "s2@test.com").await; + let _user3 = + create_user_with_password(&app.db, "stranger_user", "password123", "s3@test.com").await; + + // Make user1 and user2 friends + app::persistence::follow::follow_user(&app.db, user1.id, user2.id) + .await + .unwrap(); + app::persistence::follow::follow_user(&app.db, user2.id, user1.id) + .await + .unwrap(); + + let token1 = login_user(app.router.clone(), "search_user1", "password123").await; + let token2 = login_user(app.router.clone(), "search_user2", "password123").await; + let token3 = login_user(app.router.clone(), "stranger_user", "password123").await; + + // User1 posts thoughts with different visibilities + let thought_public = + json!({ "content": "A very public thought about Rust.", "visibility": "Public" }) + .to_string(); + let thought_friends = + json!({ "content": "A friendly thought, just for pals.", "visibility": "FriendsOnly" }) + .to_string(); + let thought_private = + json!({ "content": "A private thought, for my eyes only.", "visibility": "Private" }) + .to_string(); + + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(thought_public), + &token1, + ) + .await; + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(thought_friends), + &token1, + ) + .await; + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(thought_private), + &token1, + ) + .await; + + // 2. Run search tests + + // -- User Search -- + let response = make_get_request(app.router.clone(), "/search?q=search_user1", None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["users"]["users"].as_array().unwrap().len(), 1); + assert_eq!(v["users"]["users"][0]["username"], "search_user1"); + + // -- Thought Search (Public) -- + let response = make_get_request(app.router.clone(), "/search?q=public", None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 1, + "Guest should find public thought" + ); + assert!(v["thoughts"]["thoughts"][0]["content"] + .as_str() + .unwrap() + .contains("public")); + + // -- Thought Search (FriendsOnly) -- + let response = make_jwt_request( + app.router.clone(), + "/search?q=friendly", + "GET", + None, + &token1, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 1, + "Author should find friends thought" + ); + + let response = make_jwt_request( + app.router.clone(), + "/search?q=friendly", + "GET", + None, + &token2, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 1, + "Friend should find friends thought" + ); + + let response = make_jwt_request( + app.router.clone(), + "/search?q=friendly", + "GET", + None, + &token3, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 0, + "Stranger should NOT find friends thought" + ); + + let response = make_get_request(app.router.clone(), "/search?q=friendly", None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 0, + "Guest should NOT find friends thought" + ); + + // -- Thought Search (Private) -- + let response = make_jwt_request( + app.router.clone(), + "/search?q=private", + "GET", + None, + &token1, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 1, + "Author should find private thought" + ); + + let response = make_jwt_request( + app.router.clone(), + "/search?q=private", + "GET", + None, + &token2, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 0, + "Friend should NOT find private thought" + ); + + let response = make_get_request(app.router.clone(), "/search?q=private", None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + v["thoughts"]["thoughts"].as_array().unwrap().len(), + 0, + "Guest should NOT find private thought" + ); +}