feat: add visibility feature to thoughts, including new enum, database migration, and update related endpoints and tests
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod json;
|
mod json;
|
||||||
|
mod optional_auth;
|
||||||
mod valid;
|
mod valid;
|
||||||
|
|
||||||
pub use auth::AuthUser;
|
pub use auth::AuthUser;
|
||||||
pub use auth::Claims;
|
pub use auth::Claims;
|
||||||
pub use json::Json;
|
pub use json::Json;
|
||||||
|
pub use optional_auth::OptionalAuthUser;
|
||||||
pub use valid::Valid;
|
pub use valid::Valid;
|
||||||
|
21
thoughts-backend/api/src/extractor/optional_auth.rs
Normal file
21
thoughts-backend/api/src/extractor/optional_auth.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use super::AuthUser;
|
||||||
|
use crate::error::ApiError;
|
||||||
|
use app::state::AppState;
|
||||||
|
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||||
|
|
||||||
|
pub struct OptionalAuthUser(pub Option<AuthUser>);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for OptionalAuthUser {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
match AuthUser::from_request_parts(parts, state).await {
|
||||||
|
Ok(user) => Ok(OptionalAuthUser(Some(user))),
|
||||||
|
// If the user is not authenticated for any reason, we just treat them as a guest.
|
||||||
|
Err(_) => Ok(OptionalAuthUser(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -24,9 +24,11 @@ async fn feed_get(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> 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 mut thoughts_with_authors = get_feed_for_user(&state.conn, following_ids).await?;
|
let mut thoughts_with_authors =
|
||||||
|
get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?;
|
||||||
|
|
||||||
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
|
let own_thoughts =
|
||||||
|
get_feed_for_user(&state.conn, vec![auth_user.id], Some(auth_user.id)).await?;
|
||||||
thoughts_with_authors.extend(own_thoughts);
|
thoughts_with_authors.extend(own_thoughts);
|
||||||
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::ApiError;
|
use crate::{error::ApiError, extractor::OptionalAuthUser};
|
||||||
use app::{
|
use app::{
|
||||||
persistence::{tag, thought::get_thoughts_by_tag_name},
|
persistence::{tag, thought::get_thoughts_by_tag_name},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -20,8 +20,10 @@ use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
|||||||
async fn get_thoughts_by_tag(
|
async fn get_thoughts_by_tag(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await;
|
let thoughts_with_authors =
|
||||||
|
get_thoughts_by_tag_name(&state.conn, &tag_name, viewer.0.map(|u| u.id)).await;
|
||||||
let thoughts_with_authors = thoughts_with_authors?;
|
let thoughts_with_authors = thoughts_with_authors?;
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@@ -19,8 +19,8 @@ use models::schemas::user::{UserListSchema, UserSchema};
|
|||||||
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
||||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::models::ApiErrorResponse;
|
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
use crate::{extractor::OptionalAuthUser, models::ApiErrorResponse};
|
||||||
use crate::{
|
use crate::{
|
||||||
extractor::{Json, Valid},
|
extractor::{Json, Valid},
|
||||||
routers::api_key::create_api_key_router,
|
routers::api_key::create_api_key_router,
|
||||||
@@ -63,12 +63,14 @@ async fn users_get(
|
|||||||
async fn user_thoughts_get(
|
async fn user_thoughts_get(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let user = get_user_by_username(&state.conn, &username)
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?;
|
let thoughts_with_authors =
|
||||||
|
get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
|
||||||
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -272,12 +274,13 @@ async fn get_user_by_param(
|
|||||||
async fn user_outbox_get(
|
async fn user_outbox_get(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let user = get_user_by_username(&state.conn, &username)
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
let thoughts = get_thoughts_by_user(&state.conn, user.id).await?;
|
let thoughts = get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
|
||||||
|
|
||||||
// Format the outbox as an ActivityPub OrderedCollection
|
// Format the outbox as an ActivityPub OrderedCollection
|
||||||
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
|
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
|
||||||
|
@@ -76,3 +76,16 @@ pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, D
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_friend_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
|
let following = get_following_ids(db, user_id).await?;
|
||||||
|
let followers = get_follower_ids(db, user_id).await?;
|
||||||
|
|
||||||
|
let following_set: std::collections::HashSet<Uuid> = following.into_iter().collect();
|
||||||
|
let followers_set: std::collections::HashSet<Uuid> = followers.into_iter().collect();
|
||||||
|
|
||||||
|
Ok(following_set
|
||||||
|
.intersection(&followers_set)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType,
|
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
|
||||||
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
|
EntityTrait, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
|
||||||
|
TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
@@ -11,7 +12,10 @@ use models::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::UserError,
|
error::UserError,
|
||||||
persistence::tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
|
persistence::{
|
||||||
|
follow,
|
||||||
|
tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn create_thought(
|
pub async fn create_thought(
|
||||||
@@ -25,6 +29,7 @@ pub async fn create_thought(
|
|||||||
author_id: Set(author_id),
|
author_id: Set(author_id),
|
||||||
content: Set(params.content.clone()),
|
content: Set(params.content.clone()),
|
||||||
reply_to_id: Set(params.reply_to_id),
|
reply_to_id: Set(params.reply_to_id),
|
||||||
|
visibility: Set(params.visibility.unwrap_or(thought::Visibility::Public)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
@@ -52,7 +57,13 @@ pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr>
|
|||||||
pub async fn get_thoughts_by_user(
|
pub async fn get_thoughts_by_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
thought::Entity::find()
|
thought::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
@@ -60,8 +71,10 @@ pub async fn get_thoughts_by_user(
|
|||||||
.column(thought::Column::ReplyToId)
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
|
||||||
.filter(thought::Column::AuthorId.eq(user_id))
|
.filter(thought::Column::AuthorId.eq(user_id))
|
||||||
.order_by_desc(thought::Column::CreatedAt)
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
.into_model::<ThoughtWithAuthor>()
|
.into_model::<ThoughtWithAuthor>()
|
||||||
@@ -72,20 +85,37 @@ pub async fn get_thoughts_by_user(
|
|||||||
pub async fn get_feed_for_user(
|
pub async fn get_feed_for_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
following_ids: Vec<Uuid>,
|
following_ids: Vec<Uuid>,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
||||||
if following_ids.is_empty() {
|
if following_ids.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
thought::Entity::find()
|
thought::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
.column(thought::Column::ReplyToId)
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(
|
||||||
|
Condition::any().add(following_ids.iter().fold(
|
||||||
|
Condition::all(),
|
||||||
|
|cond, &author_id| {
|
||||||
|
cond.add(apply_visibility_filter(author_id, viewer_id, &friend_ids))
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
.filter(thought::Column::AuthorId.is_in(following_ids))
|
.filter(thought::Column::AuthorId.is_in(following_ids))
|
||||||
.order_by_desc(thought::Column::CreatedAt)
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
.into_model::<ThoughtWithAuthor>()
|
.into_model::<ThoughtWithAuthor>()
|
||||||
@@ -97,14 +127,21 @@ pub async fn get_feed_for_user(
|
|||||||
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,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
thought::Entity::find()
|
let mut friend_ids = Vec::new();
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thoughts = thought::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
.column(thought::Column::ReplyToId)
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
|
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
|
||||||
@@ -113,5 +150,49 @@ pub async fn get_thoughts_by_tag_name(
|
|||||||
.order_by_desc(thought::Column::CreatedAt)
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
.into_model::<ThoughtWithAuthor>()
|
.into_model::<ThoughtWithAuthor>()
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
let visible_thoughts = thoughts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|thought| {
|
||||||
|
let mut condition = thought.visibility == thought::Visibility::Public;
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
if thought.author_id == viewer {
|
||||||
|
condition = true;
|
||||||
|
}
|
||||||
|
if thought.visibility == thought::Visibility::FriendsOnly
|
||||||
|
&& friend_ids.contains(&thought.author_id)
|
||||||
|
{
|
||||||
|
condition = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
condition
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(visible_thoughts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_visibility_filter(
|
||||||
|
user_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
friend_ids: &[Uuid],
|
||||||
|
) -> SimpleExpr {
|
||||||
|
let mut condition =
|
||||||
|
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) {
|
||||||
|
condition =
|
||||||
|
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
condition.into()
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,59 @@
|
|||||||
|
use super::m20250905_000001_init::Thought;
|
||||||
|
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> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"CREATE TYPE thought_visibility AS ENUM ('public', 'friends_only', 'private')",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. Add the new column to the thoughts table
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(ThoughtExtension::Visibility)
|
||||||
|
.enumeration(
|
||||||
|
"thought_visibility",
|
||||||
|
["public", "friends_only", "private"],
|
||||||
|
)
|
||||||
|
.not_null()
|
||||||
|
.default("public"), // Default new thoughts to public
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Thought::Table)
|
||||||
|
.drop_column(ThoughtExtension::Visibility)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Drop the ENUM type
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("DROP TYPE thought_visibility")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum ThoughtExtension {
|
||||||
|
Visibility,
|
||||||
|
}
|
@@ -1,4 +1,19 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema,
|
||||||
|
)]
|
||||||
|
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "thought_visibility")]
|
||||||
|
pub enum Visibility {
|
||||||
|
#[sea_orm(string_value = "public")]
|
||||||
|
Public,
|
||||||
|
#[sea_orm(string_value = "friends_only")]
|
||||||
|
FriendsOnly,
|
||||||
|
#[sea_orm(string_value = "private")]
|
||||||
|
Private,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "thought")]
|
#[sea_orm(table_name = "thought")]
|
||||||
@@ -8,6 +23,7 @@ pub struct Model {
|
|||||||
pub author_id: Uuid,
|
pub author_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
pub visibility: Visibility,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,8 @@ use utoipa::ToSchema;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::domains::thought::Visibility;
|
||||||
|
|
||||||
#[derive(Deserialize, Validate, ToSchema)]
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
pub struct CreateThoughtParams {
|
pub struct CreateThoughtParams {
|
||||||
#[validate(length(
|
#[validate(length(
|
||||||
@@ -11,6 +13,6 @@ pub struct CreateThoughtParams {
|
|||||||
message = "Content must be between 1 and 128 characters"
|
message = "Content must be between 1 and 128 characters"
|
||||||
))]
|
))]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub visibility: Option<Visibility>,
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
use crate::domains::{thought, user};
|
use crate::domains::{
|
||||||
|
thought::{self, Visibility},
|
||||||
|
user,
|
||||||
|
};
|
||||||
use common::DateTimeWithTimeZoneWrapper;
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
use sea_orm::FromQueryResult;
|
use sea_orm::FromQueryResult;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -12,6 +15,7 @@ pub struct ThoughtSchema {
|
|||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
#[schema(example = "This is my first thought! #welcome")]
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
@@ -22,6 +26,7 @@ impl ThoughtSchema {
|
|||||||
id: thought.id,
|
id: thought.id,
|
||||||
author_username: author.username.clone(),
|
author_username: author.username.clone(),
|
||||||
content: thought.content.clone(),
|
content: thought.content.clone(),
|
||||||
|
visibility: thought.visibility.clone(),
|
||||||
reply_to_id: thought.reply_to_id,
|
reply_to_id: thought.reply_to_id,
|
||||||
created_at: thought.created_at.into(),
|
created_at: thought.created_at.into(),
|
||||||
}
|
}
|
||||||
@@ -44,6 +49,7 @@ pub struct ThoughtWithAuthor {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
||||||
|
pub visibility: Visibility,
|
||||||
pub author_id: Uuid,
|
pub author_id: Uuid,
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
@@ -57,6 +63,7 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
content: model.content,
|
content: model.content,
|
||||||
created_at: model.created_at.into(),
|
created_at: model.created_at.into(),
|
||||||
reply_to_id: model.reply_to_id,
|
reply_to_id: model.reply_to_id,
|
||||||
|
visibility: model.visibility,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
use crate::api::main::create_user_with_password;
|
use crate::api::main::{create_user_with_password, login_user};
|
||||||
|
|
||||||
use super::main::setup;
|
use super::main::setup;
|
||||||
use axum::http::StatusCode;
|
use app::persistence::follow;
|
||||||
|
use axum::{http::StatusCode, Router};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use utils::testing::{make_delete_request, make_post_request};
|
use utils::testing::{make_delete_request, make_get_request, make_jwt_request, make_post_request};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_thought_endpoints() {
|
async fn test_thought_endpoints() {
|
||||||
@@ -81,3 +82,84 @@ async fn test_thought_replies() {
|
|||||||
assert_eq!(reply_thought["reply_to_id"], original_thought_id);
|
assert_eq!(reply_thought["reply_to_id"], original_thought_id);
|
||||||
assert_eq!(reply_thought["author_username"], "user2");
|
assert_eq!(reply_thought["author_username"], "user2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_thought_visibility() {
|
||||||
|
let app = setup().await;
|
||||||
|
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
|
||||||
|
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
|
||||||
|
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
|
||||||
|
|
||||||
|
// Make author and friend follow each other
|
||||||
|
follow::follow_user(&app.db, author.id, friend.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
follow::follow_user(&app.db, friend.id, author.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
|
||||||
|
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
|
||||||
|
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
|
||||||
|
|
||||||
|
// Author posts one of each visibility
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(json!({"content": "public", "visibility": "Public"}).to_string()),
|
||||||
|
&author_jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(json!({"content": "friends", "visibility": "FriendsOnly"}).to_string()),
|
||||||
|
&author_jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(json!({"content": "private", "visibility": "Private"}).to_string()),
|
||||||
|
&author_jwt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Helper to get thoughts and count them
|
||||||
|
async fn get_thought_count(router: Router, jwt: Option<&str>) -> usize {
|
||||||
|
let response = if let Some(token) = jwt {
|
||||||
|
make_jwt_request(router, "/users/author/thoughts", "GET", None, token).await
|
||||||
|
} else {
|
||||||
|
make_get_request(router, "/users/author/thoughts", None).await
|
||||||
|
};
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
v["thoughts"].as_array().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), Some(&author_jwt)).await,
|
||||||
|
3,
|
||||||
|
"Author should see all their posts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), Some(&friend_jwt)).await,
|
||||||
|
2,
|
||||||
|
"Friend should see public and friends_only posts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), Some(&stranger_jwt)).await,
|
||||||
|
1,
|
||||||
|
"Stranger should see only public posts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_thought_count(app.router.clone(), None).await,
|
||||||
|
1,
|
||||||
|
"Unauthenticated guest should see only public posts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user