mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-07-14 05:26:59 +00:00
feat(core/database): implement server member
This commit is contained in:
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -342,7 +342,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"handlebars",
|
||||
"iso8601-timestamp",
|
||||
"iso8601-timestamp 0.1.10",
|
||||
"lazy_static",
|
||||
"lettre",
|
||||
"log",
|
||||
@@ -1605,6 +1605,17 @@ dependencies = [
|
||||
"time 0.3.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iso8601-timestamp"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368d05885f276773b79b4578869a60ab91e880415d17fb2a8d193aea74188a29"
|
||||
dependencies = [
|
||||
"generic-array 0.14.5",
|
||||
"serde",
|
||||
"time 0.3.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "0.4.8"
|
||||
@@ -2851,6 +2862,7 @@ dependencies = [
|
||||
"authifier",
|
||||
"bson",
|
||||
"futures",
|
||||
"iso8601-timestamp 0.2.10",
|
||||
"log",
|
||||
"mongodb",
|
||||
"nanoid",
|
||||
@@ -2956,7 +2968,7 @@ dependencies = [
|
||||
"futures",
|
||||
"impl_ops",
|
||||
"indexmap",
|
||||
"iso8601-timestamp",
|
||||
"iso8601-timestamp 0.1.10",
|
||||
"linkify 0.8.1",
|
||||
"log",
|
||||
"lru",
|
||||
@@ -3129,7 +3141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4554a81937341d8c3cd3e43431ac2d23624444dc3a88e7ffd3ff66593779293"
|
||||
dependencies = [
|
||||
"authifier",
|
||||
"iso8601-timestamp",
|
||||
"iso8601-timestamp 0.1.10",
|
||||
"revolt_okapi",
|
||||
"revolt_rocket_okapi",
|
||||
"rocket",
|
||||
|
||||
@@ -34,6 +34,7 @@ once_cell = "1.17"
|
||||
serde_json = "1"
|
||||
revolt_optional_struct = "0.2.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
|
||||
|
||||
# Database
|
||||
bson = { optional = true, version = "2.1.0" }
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use futures::lock::Mutex;
|
||||
|
||||
use crate::{Bot, File, Server, User, UserSettings};
|
||||
use crate::{Bot, File, Member, MemberCompositeKey, Server, User, UserSettings};
|
||||
|
||||
database_derived!(
|
||||
/// Reference implementation
|
||||
@@ -11,11 +11,11 @@ database_derived!(
|
||||
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
|
||||
pub user_settings: Arc<Mutex<HashMap<String, UserSettings>>>,
|
||||
pub users: Arc<Mutex<HashMap<String, User>>>,
|
||||
pub server_members: Arc<Mutex<HashMap<MemberCompositeKey, Member>>>,
|
||||
pub servers: Arc<Mutex<HashMap<String, Server>>>,
|
||||
pub files: Arc<Mutex<HashMap<String, File>>>,
|
||||
|
||||
pub server_bans: Arc<Mutex<HashMap<String, ()>>>,
|
||||
pub server_members: Arc<Mutex<HashMap<String, ()>>>,
|
||||
pub safety_reports: Arc<Mutex<HashMap<String, ()>>>,
|
||||
pub safety_snapshots: Arc<Mutex<HashMap<String, ()>>>,
|
||||
pub emoji: Arc<Mutex<HashMap<String, ()>>>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod admin_migrations;
|
||||
mod bots;
|
||||
mod files;
|
||||
mod server_members;
|
||||
mod servers;
|
||||
mod user_settings;
|
||||
mod users;
|
||||
@@ -8,6 +9,7 @@ mod users;
|
||||
pub use admin_migrations::*;
|
||||
pub use bots::*;
|
||||
pub use files::*;
|
||||
pub use server_members::*;
|
||||
pub use servers::*;
|
||||
pub use user_settings::*;
|
||||
pub use users::*;
|
||||
@@ -20,6 +22,7 @@ pub trait AbstractDatabase:
|
||||
+ admin_migrations::AbstractMigrations
|
||||
+ bots::AbstractBots
|
||||
+ files::AbstractAttachments
|
||||
+ server_members::AbstractServerMembers
|
||||
+ servers::AbstractServers
|
||||
+ user_settings::AbstractUserSettings
|
||||
+ users::AbstractUsers
|
||||
|
||||
5
crates/core/database/src/models/server_members/mod.rs
Normal file
5
crates/core/database/src/models/server_members/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod model;
|
||||
mod ops;
|
||||
|
||||
pub use model::*;
|
||||
pub use ops::*;
|
||||
118
crates/core/database/src/models/server_members/model.rs
Normal file
118
crates/core/database/src/models/server_members/model.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::{Database, File, Server};
|
||||
|
||||
auto_derived_partial!(
|
||||
/// Server Member
|
||||
pub struct Member {
|
||||
/// Unique member id
|
||||
#[serde(rename = "_id")]
|
||||
pub id: MemberCompositeKey,
|
||||
|
||||
/// Time at which this user joined the server
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub joined_at: Option<Timestamp>,
|
||||
|
||||
/// Member's nickname
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nickname: Option<String>,
|
||||
/// Avatar attachment
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<File>,
|
||||
|
||||
/// Member's roles
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub roles: Vec<String>,
|
||||
/// Timestamp this member is timed out until
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<Timestamp>,
|
||||
},
|
||||
"PartialMember"
|
||||
);
|
||||
|
||||
auto_derived!(
|
||||
/// Composite primary key consisting of server and user id
|
||||
#[derive(Hash, Default)]
|
||||
pub struct MemberCompositeKey {
|
||||
/// Server Id
|
||||
pub server: String,
|
||||
/// User Id
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// Optional fields on server member object
|
||||
pub enum FieldsMember {
|
||||
Nickname,
|
||||
Avatar,
|
||||
Roles,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Member removal intention
|
||||
pub enum RemovalIntention {
|
||||
Leave,
|
||||
Kick,
|
||||
Ban,
|
||||
}
|
||||
);
|
||||
|
||||
impl Member {
|
||||
/// Update member data
|
||||
pub async fn update<'a>(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
partial: PartialMember,
|
||||
remove: Vec<FieldsMember>,
|
||||
) -> Result<()> {
|
||||
for field in &remove {
|
||||
self.remove_field(field);
|
||||
}
|
||||
|
||||
self.apply_options(partial.clone());
|
||||
|
||||
db.update_member(&self.id, &partial, remove.clone()).await?;
|
||||
|
||||
/* // TODO: EventV1::ServerMemberUpdate {
|
||||
id: self.id.clone(),
|
||||
data: partial,
|
||||
clear: remove,
|
||||
}
|
||||
.p(self.id.server.clone())
|
||||
.await; */
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_field(&mut self, field: &FieldsMember) {
|
||||
match field {
|
||||
FieldsMember::Avatar => self.avatar = None,
|
||||
FieldsMember::Nickname => self.nickname = None,
|
||||
FieldsMember::Roles => self.roles.clear(),
|
||||
FieldsMember::Timeout => self.timeout = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get this user's current ranking
|
||||
pub fn get_ranking(&self, server: &Server) -> i64 {
|
||||
let mut value = i64::MAX;
|
||||
for role in &self.roles {
|
||||
if let Some(role) = server.roles.get(role) {
|
||||
if role.rank < value {
|
||||
value = role.rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
/// Check whether this member is in timeout
|
||||
pub fn in_timeout(&self) -> bool {
|
||||
if let Some(timeout) = self.timeout {
|
||||
*timeout > *Timestamp::now_utc()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
41
crates/core/database/src/models/server_members/ops.rs
Normal file
41
crates/core/database/src/models/server_members/ops.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember};
|
||||
|
||||
mod mongodb;
|
||||
mod reference;
|
||||
|
||||
#[async_trait]
|
||||
pub trait AbstractServerMembers: Sync + Send {
|
||||
/// Insert a new server member into the database
|
||||
async fn insert_member(&self, member: &Member) -> Result<()>;
|
||||
|
||||
/// Fetch a server member by their id
|
||||
async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member>;
|
||||
|
||||
/// Fetch all members in a server
|
||||
async fn fetch_all_members<'a>(&self, server_id: &str) -> Result<Vec<Member>>;
|
||||
|
||||
/// Fetch all memberships for a user
|
||||
async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result<Vec<Member>>;
|
||||
|
||||
/// Fetch multiple members by their ids
|
||||
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>>;
|
||||
|
||||
/// Fetch member count of a server
|
||||
async fn fetch_member_count(&self, server_id: &str) -> Result<usize>;
|
||||
|
||||
/// Fetch server count of a user
|
||||
async fn fetch_server_count(&self, user_id: &str) -> Result<usize>;
|
||||
|
||||
/// Update information for a server member
|
||||
async fn update_member(
|
||||
&self,
|
||||
id: &MemberCompositeKey,
|
||||
partial: &PartialMember,
|
||||
remove: Vec<FieldsMember>,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Delete a server member by their id
|
||||
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>;
|
||||
}
|
||||
178
crates/core/database/src/models/server_members/ops/mongodb.rs
Normal file
178
crates/core/database/src/models/server_members/ops/mongodb.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use futures::StreamExt;
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember};
|
||||
use crate::{IntoDocumentPath, MongoDb};
|
||||
|
||||
use super::AbstractServerMembers;
|
||||
|
||||
static COL: &str = "server_members";
|
||||
|
||||
#[async_trait]
|
||||
impl AbstractServerMembers for MongoDb {
|
||||
/// Insert a new server member into the database
|
||||
async fn insert_member(&self, member: &Member) -> Result<()> {
|
||||
query!(self, insert_one, COL, &member).map(|_| ())
|
||||
}
|
||||
|
||||
/// Fetch a server member by their id
|
||||
async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member> {
|
||||
query!(
|
||||
self,
|
||||
find_one,
|
||||
COL,
|
||||
doc! {
|
||||
"_id.server": server_id,
|
||||
"_id.user": user_id
|
||||
}
|
||||
)?
|
||||
.ok_or_else(|| create_error!(NotFound))
|
||||
}
|
||||
|
||||
/// Fetch all members in a server
|
||||
async fn fetch_all_members<'a>(&self, server_id: &str) -> Result<Vec<Member>> {
|
||||
Ok(self
|
||||
.col::<Member>(COL)
|
||||
.find(
|
||||
doc! {
|
||||
"_id.server": server_id
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_database_error!("find", COL))?
|
||||
.filter_map(|s| async {
|
||||
if cfg!(debug_assertions) {
|
||||
Some(s.unwrap())
|
||||
} else {
|
||||
s.ok()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Fetch all memberships for a user
|
||||
async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result<Vec<Member>> {
|
||||
Ok(self
|
||||
.col::<Member>(COL)
|
||||
.find(
|
||||
doc! {
|
||||
"_id.user": user_id
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_database_error!("find", COL))?
|
||||
.filter_map(|s| async {
|
||||
if cfg!(debug_assertions) {
|
||||
Some(s.unwrap())
|
||||
} else {
|
||||
s.ok()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Fetch multiple members by their ids
|
||||
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>> {
|
||||
Ok(self
|
||||
.col::<Member>(COL)
|
||||
.find(
|
||||
doc! {
|
||||
"_id.server": server_id,
|
||||
"_id.user": {
|
||||
"$in": ids
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_database_error!("find", COL))?
|
||||
.filter_map(|s| async {
|
||||
if cfg!(debug_assertions) {
|
||||
Some(s.unwrap())
|
||||
} else {
|
||||
s.ok()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Fetch member count of a server
|
||||
async fn fetch_member_count(&self, server_id: &str) -> Result<usize> {
|
||||
self.col::<Member>(COL)
|
||||
.count_documents(
|
||||
doc! {
|
||||
"_id.server": server_id
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|c| c as usize)
|
||||
.map_err(|_| create_database_error!("count_documents", COL))
|
||||
}
|
||||
|
||||
/// Fetch server count of a user
|
||||
async fn fetch_server_count(&self, user_id: &str) -> Result<usize> {
|
||||
self.col::<Member>(COL)
|
||||
.count_documents(
|
||||
doc! {
|
||||
"_id.user": user_id
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|c| c as usize)
|
||||
.map_err(|_| create_database_error!("count_documents", COL))
|
||||
}
|
||||
|
||||
/// Update information for a server member
|
||||
async fn update_member(
|
||||
&self,
|
||||
id: &MemberCompositeKey,
|
||||
partial: &PartialMember,
|
||||
remove: Vec<FieldsMember>,
|
||||
) -> Result<()> {
|
||||
query!(
|
||||
self,
|
||||
update_one,
|
||||
COL,
|
||||
doc! {
|
||||
"_id.server": &id.server,
|
||||
"_id.user": &id.user
|
||||
},
|
||||
partial,
|
||||
remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(),
|
||||
None
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Delete a server member by their id
|
||||
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()> {
|
||||
query!(
|
||||
self,
|
||||
delete_one,
|
||||
COL,
|
||||
doc! {
|
||||
"_id.server": &id.server,
|
||||
"_id.user": &id.user
|
||||
}
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoDocumentPath for FieldsMember {
|
||||
fn as_path(&self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
FieldsMember::Avatar => "avatar",
|
||||
FieldsMember::Nickname => "nickname",
|
||||
FieldsMember::Roles => "roles",
|
||||
FieldsMember::Timeout => "timeout",
|
||||
})
|
||||
}
|
||||
}
|
||||
117
crates/core/database/src/models/server_members/ops/reference.rs
Normal file
117
crates/core/database/src/models/server_members/ops/reference.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::ReferenceDb;
|
||||
use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember};
|
||||
|
||||
use super::AbstractServerMembers;
|
||||
|
||||
#[async_trait]
|
||||
impl AbstractServerMembers for ReferenceDb {
|
||||
/// Insert a new server member into the database
|
||||
async fn insert_member(&self, member: &Member) -> Result<()> {
|
||||
let mut server_members = self.server_members.lock().await;
|
||||
if server_members.contains_key(&member.id) {
|
||||
Err(create_database_error!("insert", "member"))
|
||||
} else {
|
||||
server_members.insert(member.id.clone(), member.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a server member by their id
|
||||
async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member> {
|
||||
let server_members = self.server_members.lock().await;
|
||||
server_members
|
||||
.get(&MemberCompositeKey {
|
||||
server: server_id.to_string(),
|
||||
user: user_id.to_string(),
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| create_error!(NotFound))
|
||||
}
|
||||
|
||||
/// Fetch all members in a server
|
||||
async fn fetch_all_members<'a>(&self, server_id: &str) -> Result<Vec<Member>> {
|
||||
let server_members = self.server_members.lock().await;
|
||||
Ok(server_members
|
||||
.values()
|
||||
.filter(|member| member.id.server == server_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Fetch all memberships for a user
|
||||
async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result<Vec<Member>> {
|
||||
let server_members = self.server_members.lock().await;
|
||||
Ok(server_members
|
||||
.values()
|
||||
.filter(|member| member.id.user == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Fetch multiple members by their ids
|
||||
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>> {
|
||||
let server_members = self.server_members.lock().await;
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
server_members
|
||||
.get(&MemberCompositeKey {
|
||||
server: server_id.to_string(),
|
||||
user: id.to_string(),
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| create_error!(NotFound))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fetch member count of a server
|
||||
async fn fetch_member_count(&self, server_id: &str) -> Result<usize> {
|
||||
let server_members = self.server_members.lock().await;
|
||||
Ok(server_members
|
||||
.values()
|
||||
.filter(|member| member.id.server == server_id)
|
||||
.count())
|
||||
}
|
||||
|
||||
/// Fetch server count of a user
|
||||
async fn fetch_server_count(&self, user_id: &str) -> Result<usize> {
|
||||
let server_members = self.server_members.lock().await;
|
||||
Ok(server_members
|
||||
.values()
|
||||
.filter(|member| member.id.user == user_id)
|
||||
.count())
|
||||
}
|
||||
|
||||
/// Update information for a server member
|
||||
async fn update_member(
|
||||
&self,
|
||||
id: &MemberCompositeKey,
|
||||
partial: &PartialMember,
|
||||
remove: Vec<FieldsMember>,
|
||||
) -> Result<()> {
|
||||
let mut server_members = self.server_members.lock().await;
|
||||
if let Some(member) = server_members.get_mut(id) {
|
||||
for field in remove {
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
member.remove_field(&field);
|
||||
}
|
||||
|
||||
member.apply_options(partial.clone());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a server member by their id
|
||||
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()> {
|
||||
let mut server_members = self.server_members.lock().await;
|
||||
if server_members.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,13 @@ impl Role {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove field from Role
|
||||
pub fn remove_field(&mut self, field: &FieldsRole) {
|
||||
match field {
|
||||
FieldsRole::Colour => self.colour = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a role
|
||||
pub async fn delete(self, db: &Database, server_id: &str, role_id: &str) -> Result<()> {
|
||||
/* // TODO: EventV1::ServerRoleDelete {
|
||||
@@ -417,13 +424,6 @@ impl Role {
|
||||
|
||||
db.delete_role(server_id, role_id).await
|
||||
}
|
||||
|
||||
/// Remove field from Role
|
||||
pub fn remove_field(&mut self, field: &FieldsRole) {
|
||||
match field {
|
||||
FieldsRole::Colour => self.colour = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemMessageChannels {
|
||||
|
||||
@@ -75,7 +75,7 @@ impl AbstractUsers for MongoDb {
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_database_error!("find", "users"))?
|
||||
.map_err(|_| create_database_error!("find", COL))?
|
||||
.filter_map(|s| async {
|
||||
if cfg!(debug_assertions) {
|
||||
Some(s.unwrap())
|
||||
@@ -101,7 +101,7 @@ impl AbstractUsers for MongoDb {
|
||||
FindOptions::builder().projection(doc! { "_id": 1 }).build(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_database_error!("find", "users"))?
|
||||
.map_err(|_| create_database_error!("find", COL))?
|
||||
.filter_map(|s| async { s.ok() })
|
||||
.map(|user| user.id)
|
||||
.collect()
|
||||
@@ -278,7 +278,7 @@ impl AbstractUsers for MongoDb {
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|_| create_database_error!("update_one", "user"))
|
||||
.map_err(|_| create_database_error!("update_one", COL))
|
||||
}
|
||||
|
||||
/// Delete a user by their id
|
||||
|
||||
Reference in New Issue
Block a user