diff --git a/migrations/2025-07-07-131320_add_friends/down.sql b/migrations/2025-07-07-131320_add_friends/down.sql new file mode 100644 index 0000000..30637b7 --- /dev/null +++ b/migrations/2025-07-07-131320_add_friends/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +DROP TABLE friend_requests; +DROP FUNCTION check_friend_request; +DROP TABLE friends; diff --git a/migrations/2025-07-07-131320_add_friends/up.sql b/migrations/2025-07-07-131320_add_friends/up.sql new file mode 100644 index 0000000..2ed45ab --- /dev/null +++ b/migrations/2025-07-07-131320_add_friends/up.sql @@ -0,0 +1,35 @@ +-- Your SQL goes here +CREATE TABLE friends ( + uuid1 UUID REFERENCES users(uuid), + uuid2 UUID REFERENCES users(uuid), + accepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (uuid1, uuid2), + CHECK (uuid1 < uuid2) +); + +CREATE TABLE friend_requests ( + sender UUID REFERENCES users(uuid), + receiver UUID REFERENCES users(uuid), + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (sender, receiver), + CHECK (sender <> receiver) +); + +-- Create a function to check for existing friendships +CREATE FUNCTION check_friend_request() +RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM friends + WHERE (uuid1, uuid2) = (LEAST(NEW.sender, NEW.receiver), GREATEST(NEW.sender, NEW.receiver)) + ) THEN + RAISE EXCEPTION 'Users are already friends'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create the trigger +CREATE TRIGGER prevent_friend_request_conflict +BEFORE INSERT OR UPDATE ON friend_requests +FOR EACH ROW EXECUTE FUNCTION check_friend_request(); diff --git a/src/api/v1/guilds/uuid/members.rs b/src/api/v1/guilds/uuid/members.rs index 972d862..5e7da58 100644 --- a/src/api/v1/guilds/uuid/members.rs +++ b/src/api/v1/guilds/uuid/members.rs @@ -1,9 +1,5 @@ use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::Member, - utils::{get_auth_header, global_checks}, + api::v1::auth::check_access_token, error::Error, objects::{Me, Member}, utils::{get_auth_header, global_checks}, Data }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, web}; @@ -28,7 +24,9 @@ pub async fn get( Member::check_membership(&mut conn, uuid, guild_uuid).await?; - let members = Member::fetch_all(&data, guild_uuid).await?; + let me = Me::get(&mut conn, uuid).await?; + + let members = Member::fetch_all(&data, &me, guild_uuid).await?; Ok(HttpResponse::Ok().json(members)) } diff --git a/src/api/v1/me/friends/mod.rs b/src/api/v1/me/friends/mod.rs index e69de29..77e1a64 100644 --- a/src/api/v1/me/friends/mod.rs +++ b/src/api/v1/me/friends/mod.rs @@ -0,0 +1,76 @@ +use actix_web::{HttpRequest, HttpResponse, get, post, web}; +use serde::Deserialize; +use ::uuid::Uuid; + +pub mod uuid; + +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + objects::Me, + utils::{get_auth_header, global_checks}, +}; + +/// Returns a list of users that are your friends +#[get("/friends")] +pub async fn get(req: HttpRequest, data: web::Data) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let me = Me::get(&mut conn, uuid).await?; + + let friends = me.get_friends(&data).await?; + + Ok(HttpResponse::Ok().json(friends)) +} + +#[derive(Deserialize)] +struct UserReq { + uuid: Uuid, +} + +/// `POST /api/v1/me/friends` Send friend request +/// +/// requires auth? yes +/// +/// ### Request Example: +/// ``` +/// json!({ +/// "uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// }); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps +/// +/// ### Responses +/// 200 Success +/// +/// 404 Not Found +/// +/// 400 Bad Request (usually means users are already friends) +/// +#[post("/friends")] +pub async fn post(req: HttpRequest, json: web::Json, data: web::Data) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let me = Me::get(&mut conn, uuid).await?; + + me.add_friend(&mut conn, json.uuid).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/api/v1/me/friends/uuid.rs b/src/api/v1/me/friends/uuid.rs new file mode 100644 index 0000000..142dbc3 --- /dev/null +++ b/src/api/v1/me/friends/uuid.rs @@ -0,0 +1,29 @@ +use actix_web::{HttpRequest, HttpResponse, delete, web}; +use uuid::Uuid; + +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + objects::Me, + utils::{get_auth_header, global_checks}, +}; + +#[delete("/friends/{uuid}")] +pub async fn delete(req: HttpRequest, path: web::Path<(Uuid,)>, data: web::Data) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let me = Me::get(&mut conn, uuid).await?; + + me.remove_friend(&mut conn, path.0).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index da5c929..dfa5fdc 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -11,12 +11,16 @@ use crate::{ }; mod guilds; +mod friends; pub fn web() -> Scope { web::scope("/me") .service(get) .service(update) .service(guilds::get) + .service(friends::get) + .service(friends::post) + .service(friends::uuid::delete) } #[get("")] diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 9e602a0..cd16c31 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -4,11 +4,7 @@ use actix_web::{HttpRequest, HttpResponse, get, web}; use uuid::Uuid; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::User, - utils::{get_auth_header, global_checks}, + api::v1::auth::check_access_token, error::Error, objects::{Me, User}, utils::{get_auth_header, global_checks}, Data }; /// `GET /api/v1/users/{uuid}` Returns user with the given UUID @@ -45,7 +41,9 @@ pub async fn get( global_checks(&data, uuid).await?; - let user = User::fetch_one(&data, user_uuid).await?; + let me = Me::get(&mut conn, uuid).await?; + + let user = User::fetch_one_with_friendship(&data, &me, user_uuid).await?; Ok(HttpResponse::Ok().json(user)) } diff --git a/src/objects/friends.rs b/src/objects/friends.rs new file mode 100644 index 0000000..a86eb2b --- /dev/null +++ b/src/objects/friends.rs @@ -0,0 +1,24 @@ +use chrono::{DateTime, Utc}; +use diesel::{Queryable, Selectable}; +use serde::Serialize; +use uuid::Uuid; + +use crate::schema::{friend_requests, friends}; + +#[derive(Serialize, Queryable, Selectable, Clone)] +#[diesel(table_name = friends)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Friend { + pub uuid1: Uuid, + pub uuid2: Uuid, + pub accepted_at: DateTime, +} + +#[derive(Serialize, Queryable, Selectable, Clone)] +#[diesel(table_name = friend_requests)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FriendRequest { + pub sender: Uuid, + pub receiver: Uuid, + pub requested_at: DateTime, +} diff --git a/src/objects/me.rs b/src/objects/me.rs index e322832..3e4e20e 100644 --- a/src/objects/me.rs +++ b/src/objects/me.rs @@ -1,5 +1,5 @@ use actix_web::web::BytesMut; -use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, update}; +use diesel::{delete, insert_into, update, ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; use diesel_async::RunQueryDsl; use serde::Serialize; use tokio::task; @@ -7,10 +7,7 @@ use url::Url; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - schema::{guild_members, guilds, users}, - utils::{EMAIL_REGEX, USERNAME_REGEX, image_check}, + error::Error, objects::{FriendRequest, Friend, User}, schema::{friend_requests, friends, guild_members, guilds, users}, utils::{image_check, EMAIL_REGEX, USERNAME_REGEX}, Conn, Data }; use super::{Guild, guild::GuildBuilder, load_or_empty, member::MemberBuilder}; @@ -153,13 +150,11 @@ impl Me { ) -> Result<(), Error> { let mut conn = data.pool.get().await?; - let new_display_name_option; - - if new_display_name.is_empty() { - new_display_name_option = None; + let new_display_name_option = if new_display_name.is_empty() { + None } else { - new_display_name_option = Some(new_display_name) - } + Some(new_display_name) + }; use users::dsl; update(users::table) @@ -236,4 +231,175 @@ impl Me { Ok(()) } + + pub async fn friends_with(&self, conn: &mut Conn, user_uuid: Uuid) -> Result, Error> { + use friends::dsl; + + let friends: Vec = if self.uuid < user_uuid { + load_or_empty( + dsl::friends + .filter(dsl::uuid1.eq(self.uuid)) + .filter(dsl::uuid2.eq(user_uuid)) + .load(conn) + .await + )? + } else { + load_or_empty( + dsl::friends + .filter(dsl::uuid1.eq(user_uuid)) + .filter(dsl::uuid2.eq(self.uuid)) + .load(conn) + .await + )? + }; + + if friends.is_empty() { + return Ok(None) + } + + Ok(Some(friends[0].clone())) + } + + pub async fn add_friend(&self, conn: &mut Conn, user_uuid: Uuid) -> Result<(), Error> { + if self.friends_with(conn, user_uuid).await?.is_some() { + // TODO: Check if another error should be used + return Err(Error::BadRequest("Already friends with user".to_string())) + } + + use friend_requests::dsl; + + let friend_request: Vec = load_or_empty( + dsl::friend_requests + .filter(dsl::sender.eq(user_uuid)) + .filter(dsl::receiver.eq(self.uuid)) + .load(conn) + .await + )?; + + #[allow(clippy::get_first)] + if let Some(friend_request) = friend_request.get(0) { + use friends::dsl; + + if self.uuid < user_uuid { + insert_into(friends::table) + .values((dsl::uuid1.eq(self.uuid), dsl::uuid2.eq(user_uuid))) + .execute(conn) + .await?; + } else { + insert_into(friends::table) + .values((dsl::uuid1.eq(user_uuid), dsl::uuid2.eq(self.uuid))) + .execute(conn) + .await?; + } + + use friend_requests::dsl as frdsl; + + delete(friend_requests::table) + .filter(frdsl::sender.eq(friend_request.sender)) + .filter(frdsl::receiver.eq(friend_request.receiver)) + .execute(conn) + .await?; + + Ok(()) + } else { + use friend_requests::dsl; + + insert_into(friend_requests::table) + .values((dsl::sender.eq(self.uuid), dsl::receiver.eq(user_uuid))) + .execute(conn) + .await?; + + Ok(()) + } + } + + pub async fn remove_friend(&self, conn: &mut Conn, user_uuid: Uuid) -> Result<(), Error> { + if self.friends_with(conn, user_uuid).await?.is_none() { + // TODO: Check if another error should be used + return Err(Error::BadRequest("Not friends with user".to_string())) + } + + use friends::dsl; + + if self.uuid < user_uuid { + delete(friends::table) + .filter(dsl::uuid1.eq(self.uuid)) + .filter(dsl::uuid2.eq(user_uuid)) + .execute(conn) + .await?; + } else { + delete(friends::table) + .filter(dsl::uuid1.eq(user_uuid)) + .filter(dsl::uuid2.eq(self.uuid)) + .execute(conn) + .await?; + } + + Ok(()) + } + + pub async fn get_friends(&self, data: &Data) -> Result, Error> { + use friends::dsl; + + let mut conn = data.pool.get().await?; + + let friends1 = load_or_empty( + dsl::friends + .filter(dsl::uuid1.eq(self.uuid)) + .select(Friend::as_select()) + .load(&mut conn) + .await + )?; + + let friends2 = load_or_empty( + dsl::friends + .filter(dsl::uuid2.eq(self.uuid)) + .select(Friend::as_select()) + .load(&mut conn) + .await + )?; + + let friend_futures = friends1.iter().map(async move |friend| { + User::fetch_one_with_friendship(data, self, friend.uuid2).await + }); + + let mut friends = futures::future::try_join_all(friend_futures).await?; + + let friend_futures = friends2.iter().map(async move |friend| { + User::fetch_one_with_friendship(data, self, friend.uuid1).await + }); + + friends.append(&mut futures::future::try_join_all(friend_futures).await?); + + Ok(friends) + } + + /* TODO + pub async fn get_friend_requests(&self, conn: &mut Conn) -> Result, Error> { + use friend_requests::dsl; + + let friend_request: Vec = load_or_empty( + dsl::friend_requests + .filter(dsl::receiver.eq(self.uuid)) + .load(conn) + .await + )?; + + Ok() + } + + pub async fn delete_friend_request(&self, conn: &mut Conn, user_uuid: Uuid) -> Result, Error> { + use friend_requests::dsl; + + let friend_request: Vec = load_or_empty( + dsl::friend_requests + .filter(dsl::sender.eq(user_uuid)) + .filter(dsl::receiver.eq(self.uuid)) + .load(conn) + .await + )?; + + Ok() + } + */ } diff --git a/src/objects/member.rs b/src/objects/member.rs index d33d2b6..d11126c 100644 --- a/src/objects/member.rs +++ b/src/objects/member.rs @@ -6,10 +6,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - objects::{Permissions, Role}, - schema::guild_members, + error::Error, objects::{Me, Permissions, Role}, schema::guild_members, Conn, Data }; use super::{User, load_or_empty}; @@ -26,8 +23,14 @@ pub struct MemberBuilder { } impl MemberBuilder { - pub async fn build(&self, data: &Data) -> Result { - let user = User::fetch_one(data, self.user_uuid).await?; + pub async fn build(&self, data: &Data, me: Option<&Me>) -> Result { + let user; + + if let Some(me) = me { + user = User::fetch_one_with_friendship(data, me, self.user_uuid).await?; + } else { + user = User::fetch_one(data, self.user_uuid).await?; + } Ok(Member { uuid: self.uuid, @@ -94,7 +97,7 @@ impl Member { Ok(member_builder) } - pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + pub async fn fetch_one(data: &Data, me: &Me, user_uuid: Uuid, guild_uuid: Uuid) -> Result { let mut conn = data.pool.get().await?; use guild_members::dsl; @@ -105,10 +108,10 @@ impl Member { .get_result(&mut conn) .await?; - member.build(data).await + member.build(data, Some(me)).await } - pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result, Error> { + pub async fn fetch_all(data: &Data, me: &Me, guild_uuid: Uuid) -> Result, Error> { let mut conn = data.pool.get().await?; use guild_members::dsl; @@ -122,7 +125,7 @@ impl Member { let member_futures = member_builders .iter() - .map(async move |m| m.build(data).await); + .map(async move |m| m.build(data, Some(me)).await); futures::future::try_join_all(member_futures).await } @@ -145,6 +148,6 @@ impl Member { .execute(&mut conn) .await?; - member.build(data).await + member.build(data, None).await } } diff --git a/src/objects/mod.rs b/src/objects/mod.rs index d8de266..2ca58e6 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -17,6 +17,7 @@ mod message; mod password_reset_token; mod role; mod user; +mod friends; pub use channel::Channel; pub use email_token::EmailToken; @@ -29,6 +30,8 @@ pub use password_reset_token::PasswordResetToken; pub use role::Permissions; pub use role::Role; pub use user::User; +pub use friends::Friend; +pub use friends::FriendRequest; use crate::error::Error; diff --git a/src/objects/user.rs b/src/objects/user.rs index 98e5e80..eb28694 100644 --- a/src/objects/user.rs +++ b/src/objects/user.rs @@ -1,15 +1,40 @@ +use chrono::{DateTime, Utc}; use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; use diesel_async::RunQueryDsl; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, Data, error::Error, schema::users}; +use crate::{error::Error, objects::Me, schema::users, Conn, Data}; use super::load_or_empty; #[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] #[diesel(table_name = users)] #[diesel(check_for_backend(diesel::pg::Pg))] +pub struct UserBuilder { + uuid: Uuid, + username: String, + display_name: Option, + avatar: Option, + pronouns: Option, + about: Option, +} + +impl UserBuilder { + fn build(self) -> User { + User { + uuid: self.uuid, + username: self.username, + display_name: self.display_name, + avatar: self.avatar, + pronouns: self.pronouns, + about: self.about, + friends_since: None, + } + } +} + +#[derive(Deserialize, Serialize, Clone)] pub struct User { uuid: Uuid, username: String, @@ -17,6 +42,7 @@ pub struct User { avatar: Option, pronouns: Option, about: Option, + friends_since: Option>, } impl User { @@ -28,33 +54,49 @@ impl User { } use users::dsl; - let user: User = dsl::users + let user_builder: UserBuilder = dsl::users .filter(dsl::uuid.eq(user_uuid)) - .select(User::as_select()) + .select(UserBuilder::as_select()) .get_result(&mut conn) .await?; + let user = user_builder.build(); + data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) .await?; Ok(user) } + pub async fn fetch_one_with_friendship(data: &Data, me: &Me, user_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + let mut user = Self::fetch_one(data, user_uuid).await?; + + if let Some(friend) = me.friends_with(&mut conn, user_uuid).await? { + user.friends_since = Some(friend.accepted_at); + } + + Ok(user) + } + pub async fn fetch_amount( conn: &mut Conn, offset: i64, amount: i64, ) -> Result, Error> { use users::dsl; - let users: Vec = load_or_empty( + let user_builders: Vec = load_or_empty( dsl::users .limit(amount) .offset(offset) - .select(User::as_select()) + .select(UserBuilder::as_select()) .load(conn) .await, )?; + let users: Vec = user_builders.iter().map(|u| u.clone().build()).collect(); + Ok(users) } } diff --git a/src/schema.rs b/src/schema.rs index f860b31..2693b02 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,6 +31,22 @@ diesel::table! { } } +diesel::table! { + friend_requests (sender, receiver) { + sender -> Uuid, + receiver -> Uuid, + requested_at -> Timestamptz, + } +} + +diesel::table! { + friends (uuid1, uuid2) { + uuid1 -> Uuid, + uuid2 -> Uuid, + accepted_at -> Timestamptz, + } +} + diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -153,6 +169,8 @@ diesel::allow_tables_to_appear_in_same_query!( access_tokens, channel_permissions, channels, + friend_requests, + friends, guild_members, guilds, instance_permissions,