feat: add friends!

This commit is contained in:
Radical 2025-07-10 15:37:38 +02:00
parent ac3e7e242b
commit e8b8b49643
13 changed files with 439 additions and 39 deletions

View file

@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
DROP TABLE friend_requests;
DROP FUNCTION check_friend_request;
DROP TABLE friends;

View file

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

View file

@ -1,9 +1,5 @@
use crate::{ use crate::{
Data, api::v1::auth::check_access_token, error::Error, objects::{Me, Member}, utils::{get_auth_header, global_checks}, Data
api::v1::auth::check_access_token,
error::Error,
objects::Member,
utils::{get_auth_header, global_checks},
}; };
use ::uuid::Uuid; use ::uuid::Uuid;
use actix_web::{HttpRequest, HttpResponse, get, web}; use actix_web::{HttpRequest, HttpResponse, get, web};
@ -28,7 +24,9 @@ pub async fn get(
Member::check_membership(&mut conn, uuid, guild_uuid).await?; 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)) Ok(HttpResponse::Ok().json(members))
} }

View file

@ -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<Data>) -> Result<HttpResponse, Error> {
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<UserReq>, data: web::Data<Data>) -> Result<HttpResponse, Error> {
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())
}

View file

@ -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<Data>) -> Result<HttpResponse, Error> {
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())
}

View file

@ -11,12 +11,16 @@ use crate::{
}; };
mod guilds; mod guilds;
mod friends;
pub fn web() -> Scope { pub fn web() -> Scope {
web::scope("/me") web::scope("/me")
.service(get) .service(get)
.service(update) .service(update)
.service(guilds::get) .service(guilds::get)
.service(friends::get)
.service(friends::post)
.service(friends::uuid::delete)
} }
#[get("")] #[get("")]

View file

@ -4,11 +4,7 @@ use actix_web::{HttpRequest, HttpResponse, get, web};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
Data, api::v1::auth::check_access_token, error::Error, objects::{Me, User}, utils::{get_auth_header, global_checks}, Data
api::v1::auth::check_access_token,
error::Error,
objects::User,
utils::{get_auth_header, global_checks},
}; };
/// `GET /api/v1/users/{uuid}` Returns user with the given UUID /// `GET /api/v1/users/{uuid}` Returns user with the given UUID
@ -45,7 +41,9 @@ pub async fn get(
global_checks(&data, uuid).await?; 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)) Ok(HttpResponse::Ok().json(user))
} }

24
src/objects/friends.rs Normal file
View file

@ -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<Utc>,
}
#[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<Utc>,
}

View file

@ -1,5 +1,5 @@
use actix_web::web::BytesMut; 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 diesel_async::RunQueryDsl;
use serde::Serialize; use serde::Serialize;
use tokio::task; use tokio::task;
@ -7,10 +7,7 @@ use url::Url;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
Conn, Data, error::Error, objects::{FriendRequest, Friend, User}, schema::{friend_requests, friends, guild_members, guilds, users}, utils::{image_check, EMAIL_REGEX, USERNAME_REGEX}, Conn, Data
error::Error,
schema::{guild_members, guilds, users},
utils::{EMAIL_REGEX, USERNAME_REGEX, image_check},
}; };
use super::{Guild, guild::GuildBuilder, load_or_empty, member::MemberBuilder}; use super::{Guild, guild::GuildBuilder, load_or_empty, member::MemberBuilder};
@ -153,13 +150,11 @@ impl Me {
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut conn = data.pool.get().await?; let mut conn = data.pool.get().await?;
let new_display_name_option; let new_display_name_option = if new_display_name.is_empty() {
None
if new_display_name.is_empty() {
new_display_name_option = None;
} else { } else {
new_display_name_option = Some(new_display_name) Some(new_display_name)
} };
use users::dsl; use users::dsl;
update(users::table) update(users::table)
@ -236,4 +231,175 @@ impl Me {
Ok(()) Ok(())
} }
pub async fn friends_with(&self, conn: &mut Conn, user_uuid: Uuid) -> Result<Option<Friend>, Error> {
use friends::dsl;
let friends: Vec<Friend> = 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<FriendRequest> = 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<Vec<User>, 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<Vec<FriendRequest>, Error> {
use friend_requests::dsl;
let friend_request: Vec<FriendRequest> = 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<Vec<FriendRequest>, Error> {
use friend_requests::dsl;
let friend_request: Vec<FriendRequest> = load_or_empty(
dsl::friend_requests
.filter(dsl::sender.eq(user_uuid))
.filter(dsl::receiver.eq(self.uuid))
.load(conn)
.await
)?;
Ok()
}
*/
} }

View file

@ -6,10 +6,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
Conn, Data, error::Error, objects::{Me, Permissions, Role}, schema::guild_members, Conn, Data
error::Error,
objects::{Permissions, Role},
schema::guild_members,
}; };
use super::{User, load_or_empty}; use super::{User, load_or_empty};
@ -26,8 +23,14 @@ pub struct MemberBuilder {
} }
impl MemberBuilder { impl MemberBuilder {
pub async fn build(&self, data: &Data) -> Result<Member, Error> { pub async fn build(&self, data: &Data, me: Option<&Me>) -> Result<Member, Error> {
let user = User::fetch_one(data, self.user_uuid).await?; 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 { Ok(Member {
uuid: self.uuid, uuid: self.uuid,
@ -94,7 +97,7 @@ impl Member {
Ok(member_builder) Ok(member_builder)
} }
pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result<Self, Error> { pub async fn fetch_one(data: &Data, me: &Me, user_uuid: Uuid, guild_uuid: Uuid) -> Result<Self, Error> {
let mut conn = data.pool.get().await?; let mut conn = data.pool.get().await?;
use guild_members::dsl; use guild_members::dsl;
@ -105,10 +108,10 @@ impl Member {
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?;
member.build(data).await member.build(data, Some(me)).await
} }
pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result<Vec<Self>, Error> { pub async fn fetch_all(data: &Data, me: &Me, guild_uuid: Uuid) -> Result<Vec<Self>, Error> {
let mut conn = data.pool.get().await?; let mut conn = data.pool.get().await?;
use guild_members::dsl; use guild_members::dsl;
@ -122,7 +125,7 @@ impl Member {
let member_futures = member_builders let member_futures = member_builders
.iter() .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 futures::future::try_join_all(member_futures).await
} }
@ -145,6 +148,6 @@ impl Member {
.execute(&mut conn) .execute(&mut conn)
.await?; .await?;
member.build(data).await member.build(data, None).await
} }
} }

View file

@ -17,6 +17,7 @@ mod message;
mod password_reset_token; mod password_reset_token;
mod role; mod role;
mod user; mod user;
mod friends;
pub use channel::Channel; pub use channel::Channel;
pub use email_token::EmailToken; pub use email_token::EmailToken;
@ -29,6 +30,8 @@ pub use password_reset_token::PasswordResetToken;
pub use role::Permissions; pub use role::Permissions;
pub use role::Role; pub use role::Role;
pub use user::User; pub use user::User;
pub use friends::Friend;
pub use friends::FriendRequest;
use crate::error::Error; use crate::error::Error;

View file

@ -1,15 +1,40 @@
use chrono::{DateTime, Utc};
use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper};
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; 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; use super::load_or_empty;
#[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] #[derive(Deserialize, Serialize, Clone, Queryable, Selectable)]
#[diesel(table_name = users)] #[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))] #[diesel(check_for_backend(diesel::pg::Pg))]
pub struct UserBuilder {
uuid: Uuid,
username: String,
display_name: Option<String>,
avatar: Option<String>,
pronouns: Option<String>,
about: Option<String>,
}
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 { pub struct User {
uuid: Uuid, uuid: Uuid,
username: String, username: String,
@ -17,6 +42,7 @@ pub struct User {
avatar: Option<String>, avatar: Option<String>,
pronouns: Option<String>, pronouns: Option<String>,
about: Option<String>, about: Option<String>,
friends_since: Option<DateTime<Utc>>,
} }
impl User { impl User {
@ -28,33 +54,49 @@ impl User {
} }
use users::dsl; use users::dsl;
let user: User = dsl::users let user_builder: UserBuilder = dsl::users
.filter(dsl::uuid.eq(user_uuid)) .filter(dsl::uuid.eq(user_uuid))
.select(User::as_select()) .select(UserBuilder::as_select())
.get_result(&mut conn) .get_result(&mut conn)
.await?; .await?;
let user = user_builder.build();
data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) data.set_cache_key(user_uuid.to_string(), user.clone(), 1800)
.await?; .await?;
Ok(user) Ok(user)
} }
pub async fn fetch_one_with_friendship(data: &Data, me: &Me, user_uuid: Uuid) -> Result<Self, Error> {
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( pub async fn fetch_amount(
conn: &mut Conn, conn: &mut Conn,
offset: i64, offset: i64,
amount: i64, amount: i64,
) -> Result<Vec<Self>, Error> { ) -> Result<Vec<Self>, Error> {
use users::dsl; use users::dsl;
let users: Vec<User> = load_or_empty( let user_builders: Vec<UserBuilder> = load_or_empty(
dsl::users dsl::users
.limit(amount) .limit(amount)
.offset(offset) .offset(offset)
.select(User::as_select()) .select(UserBuilder::as_select())
.load(conn) .load(conn)
.await, .await,
)?; )?;
let users: Vec<User> = user_builders.iter().map(|u| u.clone().build()).collect();
Ok(users) Ok(users)
} }
} }

View file

@ -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! { diesel::table! {
guild_members (uuid) { guild_members (uuid) {
uuid -> Uuid, uuid -> Uuid,
@ -153,6 +169,8 @@ diesel::allow_tables_to_appear_in_same_query!(
access_tokens, access_tokens,
channel_permissions, channel_permissions,
channels, channels,
friend_requests,
friends,
guild_members, guild_members,
guilds, guilds,
instance_permissions, instance_permissions,