Repository: revoltchat/backend (language: rust)

Issue:
feat: implement discriminators
This is currently in RFC stage, [please discuss here](https://github.com/revoltchat/rfcs/pull/3).


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 40aec27eb..7d10d349e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2867,7 +2867,9 @@ dependencies = [
  "mongodb",
  "nanoid",
  "once_cell",
+ "rand 0.8.5",
  "redis-kiss",
+ "regex",
  "revolt-models",
  "revolt-permissions",
  "revolt-presence",
@@ -2878,6 +2880,7 @@ dependencies = [
  "serde",
  "serde_json",
  "ulid 1.0.0",
+ "unicode-segmentation",
 ]
 
 [[package]]
@@ -4327,6 +4330,12 @@ dependencies = [
  "tinyvec",
 ]
 
+[[package]]
+name = "unicode-segmentation"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+
 [[package]]
 name = "unicode-xid"
 version = "0.0.4"
diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml
index 6dc17941c..08533b188 100644
--- a/crates/core/database/Cargo.toml
+++ b/crates/core/database/Cargo.toml
@@ -29,6 +29,7 @@ revolt-permissions = { version = "0.6.0-rc.2", path = "../permissions", features
 
 # Utility
 log = "0.4"
+rand = "0.8.5"
 ulid = "1.0.0"
 nanoid = "0.4.0"
 once_cell = "1.17"
@@ -46,6 +47,10 @@ redis-kiss = { version = "0.1.4" }
 bson = { optional = true, version = "2.1.0" }
 mongodb = { optional = true, version = "2.1.0", default-features = false }
 
+# Database Migration
+unicode-segmentation = "1.10.1"
+regex = "1"
+
 # Async Language Features
 futures = "0.3.19"
 async-trait = "0.1.51"
diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs
index 352d04ad9..729a8b318 100644
--- a/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs
+++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs
@@ -44,6 +44,10 @@ pub async fn create_database(db: &MongoDb) {
         .await
         .expect("Failed to create channel_unreads collection.");
 
+    db.create_collection("channel_webhooks", None)
+        .await
+        .expect("Failed to create channel_webhooks collection.");
+
     db.create_collection("migrations", None)
         .await
         .expect("Failed to create migrations collection.");
@@ -91,6 +95,18 @@ pub async fn create_database(db: &MongoDb) {
                         "username": 1_i32
                     },
                     "name": "username",
+                    "unique": false,
+                    "collation": {
+                        "locale": "en",
+                        "strength": 2_i32
+                    }
+                },
+                {
+                    "key": {
+                        "username": 1_i32,
+                        "discriminator": 1_i32
+                    },
+                    "name": "username_discriminator",
                     "unique": true,
                     "collation": {
                         "locale": "en",
diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs
index 7a9a8d38e..6b822380a 100644
--- a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs
+++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs
@@ -1,14 +1,16 @@
-use std::{ops::BitXor, time::Duration};
+use std::{collections::HashSet, ops::BitXor, time::Duration};
 
 use crate::{
     mongodb::{
         bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document},
         options::FindOptions,
     },
-    MongoDb,
+    MongoDb, DISCRIMINATOR_SEARCH_SPACE,
 };
 use futures::StreamExt;
+use rand::seq::SliceRandom;
 use serde::{Deserialize, Serialize};
+use unicode_segmentation::UnicodeSegmentation;
 
 #[derive(Serialize, Deserialize)]
 struct MigrationInfo {
@@ -16,7 +18,7 @@ struct MigrationInfo {
     revision: i32,
 }
 
-pub const LATEST_REVISION: i32 = 23;
+pub const LATEST_REVISION: i32 = 25;
 
 pub async fn migrate_database(db: &MongoDb) {
     let migrations = db.col::<Document>("migrations");
@@ -768,6 +770,181 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
             .expect("Failed to update server members.");
     }
 
+    if revision <= 23 {
+        info!("Running migration [revision 23 / 10-06-2023]: Generate discriminators for users.");
+
+        db.db()
+            .run_command(
+                doc! {
+                    "dropIndexes": "users",
+                    "index": "username"
+                },
+                None,
+            )
+            .await
+            .expect("Failed to drop existing username index.");
+
+        #[derive(Serialize, Deserialize)]
+        struct UserInformation {
+            #[serde(rename = "_id")]
+            id: String,
+            username: String,
+        }
+
+        let re_username = regex::Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap();
+
+        let users: Vec<UserInformation> = db
+            .col::<UserInformation>("users")
+            .find(doc! {}, None)
+            .await
+            .unwrap()
+            .map(|doc| doc.expect("id and username"))
+            .collect()
+            .await;
+
+        let search_space: Vec<String> = DISCRIMINATOR_SEARCH_SPACE.iter().cloned().collect();
+        let mut claimed: HashSet<String> = HashSet::new();
+
+        for i in 0..users.len() {
+            let info = &users[i];
+            let mut discriminator = {
+                let mut rng = rand::thread_rng();
+                search_space.choose(&mut rng).unwrap()
+            };
+
+            if re_username.is_match(&info.username) {
+                while claimed.contains(&format!("{}#{}", info.username, discriminator)) {
+                    let new_discriminator = {
+                        let mut rng = rand::thread_rng();
+                        search_space.choose(&mut rng).unwrap()
+                    };
+
+                    info!(
+                        "Re-rolled {} to {new_discriminator} from {discriminator}",
+                        info.username
+                    );
+
+                    discriminator = new_discriminator;
+                }
+
+                claimed.insert(format!("{}#{}", info.username, discriminator));
+
+                info!(
+                    "({i}/{}) Migrating user \"{}\" to #{} - compliant",
+                    users.len(),
+                    info.username,
+                    discriminator
+                );
+
+                db.col::<UserInformation>("users")
+                    .update_one(
+                        doc! {
+                            "_id": &info.id
+                        },
+                        doc! {
+                            "$set": {
+                                "discriminator": discriminator
+                            }
+                        },
+                        None,
+                    )
+                    .await
+                    .unwrap();
+            } else {
+                let mut sanitised = info
+                    .username
+                    .graphemes(true)
+                    .filter(|s| re_username.is_match(s))
+                    .collect::<String>();
+
+                while sanitised.len() < 2 {
+                    sanitised += "_";
+                }
+
+                while claimed.contains(&format!("{}#{}", sanitised, discriminator)) {
+                    let new_discriminator = {
+                        let mut rng = rand::thread_rng();
+                        search_space.choose(&mut rng).unwrap()
+                    };
+
+                    info!("Re-rolled {sanitised} to {new_discriminator} from {discriminator}");
+                    discriminator = new_discriminator;
+                }
+
+                claimed.insert(format!("{}#{}", sanitised, discriminator));
+
+                info!(
+                    "({i}/{}) Migrating user \"{}\" to #{} - sanitised: \"{}\"",
+                    users.len(),
+                    info.username,
+                    discriminator,
+                    sanitised
+                );
+
+                db.col::<UserInformation>("users")
+                    .update_one(
+                        doc! {
+                            "_id": &info.id
+                        },
+                        doc! {
+                            "$set": {
+                                "username": sanitised,
+                                "discriminator": discriminator,
+                                "display_name": &info.username
+                            }
+                        },
+                        None,
+                    )
+                    .await
+                    .unwrap();
+            }
+        }
+    }
+
+    if revision <= 24 {
+        info!("Running migration [revision 24 / 09-06-2023]: Add collection `channel_webhooks` if not exists, update users index.");
+
+        db.db()
+            .create_collection("channel_webhooks", None)
+            .await
+            .ok();
+
+        db.db()
+            .run_command(
+                doc! {
+                    "createIndexes": "users",
+                    "indexes": [
+                        {
+                            "key": {
+                                "username": 1_i32
+                            },
+                            "name": "username",
+                            "unique": false,
+                            "collation": {
+                                "locale": "en",
+                                "strength": 2_i32
+                            }
+                        },
+                        {
+                            "key": {
+                                "username": 1_i32,
+                                "discriminator": 1_i32
+                            },
+                            "name": "username_discriminator",
+                            "unique": true,
+                            "collation": {
+                                "locale": "en",
+                                "strength": 2_i32
+                            }
+                        }
+                    ]
+                },
+                None,
+            )
+            .await
+            .expect("Failed to create username index.");
+    }
+
     // Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
 
     // Reminder to update LATEST_REVISION when adding new migrations.
diff --git a/crates/core/database/src/models/users/model.rs b/crates/core/database/src/models/users/model.rs
index c70e08397..0ea2d08ba 100644
--- a/crates/core/database/src/models/users/model.rs
+++ b/crates/core/database/src/models/users/model.rs
@@ -1,5 +1,8 @@
+use std::collections::HashSet;
+
 use crate::{Database, File};
 
+use once_cell::sync::Lazy;
 use revolt_result::{Error, ErrorType, Result};
 
 auto_derived_partial!(
@@ -10,6 +13,10 @@ auto_derived_partial!(
         pub id: String,
         /// Username
         pub username: String,
+        /// Discriminator
+        pub discriminator: String,
+        /// Display name
+        pub display_name: String,
         #[serde(skip_serializing_if = "Option::is_none")]
         /// Avatar attachment
         pub avatar: Option<File>,
@@ -195,3 +202,15 @@ impl User {
         .await
     }
 }
+
+pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
+    let mut set = (2..9999)
+        .map(|v| format!("{:0>4}", v))
+        .collect::<HashSet<String>>();
+
+    for discrim in [1111, 2222, 3333, 4444, 5555, 6666, 7777, 8888, 9999] {
+        set.remove(&discrim.to_string());
+    }
+
+    set.into_iter().collect()
+});
diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs
index 4d6c13c97..45fad9035 100644
--- a/crates/core/database/src/util/bridge/v0.rs
+++ b/crates/core/database/src/util/bridge/v0.rs
@@ -257,6 +257,8 @@ impl crate::User {
 
         User {
             username: self.username,
+            discriminator: self.discriminator,
+            display_name: self.display_name,
             avatar: self.avatar.map(|file| file.into()),
             relations: vec![],
             badges: self.badges.unwrap_or_default() as u32,
diff --git a/crates/core/models/src/v0/users.rs b/crates/core/models/src/v0/users.rs
index 8224639fa..4e34a3ec5 100644
--- a/crates/core/models/src/v0/users.rs
+++ b/crates/core/models/src/v0/users.rs
@@ -8,6 +8,10 @@ auto_derived!(
         pub id: String,
         /// Username
         pub username: String,
+        /// Discriminator
+        pub discriminator: String,
+        /// Display name
+        pub display_name: String,
         #[serde(skip_serializing_if = "Option::is_none")]
         /// Avatar attachment
         pub avatar: Option<File>,
diff --git a/crates/delta/src/routes/bots/create.rs b/crates/delta/src/routes/bots/create.rs
index 3af72e87f..06f70089c 100644
--- a/crates/delta/src/routes/bots/create.rs
+++ b/crates/delta/src/routes/bots/create.rs
@@ -38,14 +38,12 @@ pub async fn create_bot(db: &Db, user: User, info: Json<DataCreateBot>) -> Resul
         return Err(Error::ReachedMaximumBots);
     }
 
-    if db.is_username_taken(&info.name).await? {
-        return Err(Error::UsernameTaken);
-    }
-
     let id = Ulid::new().to_string();
+    let username = User::validate_username(info.name)?;
     let bot_user = User {
         id: id.clone(),
-        username: info.name.trim().to_string(),
+        discriminator: User::find_discriminator(db, &username, None).await?,
+        username,
         bot: Some(BotInformation {
             owner: user.id.clone(),
         }),
diff --git a/crates/delta/src/routes/bots/edit.rs b/crates/delta/src/routes/bots/edit.rs
index 0395744fc..7e22adb2c 100644
--- a/crates/delta/src/routes/bots/edit.rs
+++ b/crates/delta/src/routes/bots/edit.rs
@@ -58,10 +58,6 @@ pub async fn edit_bot(
     }
 
     if let Some(name) = data.name {
-        if db.is_username_taken(&name).await? {
-            return Err(Error::UsernameTaken);
-        }
-
         let mut user = db.fetch_user(&bot.id).await?;
         user.update_username(db, name).await?;
     }
diff --git a/crates/delta/src/routes/onboard/complete.rs b/crates/delta/src/routes/onboard/complete.rs
index a96444f7a..90f7136ff 100644
--- a/crates/delta/src/routes/onboard/complete.rs
+++ b/crates/delta/src/routes/onboard/complete.rs
@@ -34,9 +34,10 @@ pub async fn req(
     data.validate()
         .map_err(|error| Error::FailedValidation { error })?;
 
-    let username = User::validate_username(db, data.username).await?;
+    let username = User::validate_username(data.username)?;
     let user = User {
         id: session.user_id,
+        discriminator: User::find_discriminator(db, &username, None).await?,
         username,
         ..Default::default()
     };
diff --git a/crates/delta/src/routes/users/edit_user.rs b/crates/delta/src/routes/users/edit_user.rs
index e91d89195..76e04a21c 100644
--- a/crates/delta/src/routes/users/edit_user.rs
+++ b/crates/delta/src/routes/users/edit_user.rs
@@ -8,6 +8,8 @@ use rocket::State;
 use serde::{Deserialize, Serialize};
 use validator::Validate;
 
+use crate::util::regex::RE_DISPLAY_NAME;
+
 /// # Profile Data
 #[derive(Validate, Serialize, Deserialize, Debug, JsonSchema)]
 pub struct UserProfileData {
@@ -24,6 +26,10 @@ pub struct UserProfileData {
 /// # User Data
 #[derive(Validate, Serialize, Deserialize, JsonSchema)]
 pub struct DataEditUser {
+    /// New display name
+    #[serde(rename = "displayName")]
+    #[validate(length(min = 2, max = 32), regex = "RE_DISPLAY_NAME")]
+    display_name: Option<String>,
     /// Attachment Id for avatar
     #[validate(length(min = 1, max = 128))]
     avatar: Option<String>,
@@ -84,7 +90,8 @@ pub async fn req(
     }
 
     // Exit out early if nothing is changed
-    if data.status.is_none()
+    if data.display_name.is_none()
+        && data.status.is_none()
         && data.profile.is_none()
         && data.avatar.is_none()
         && data.badges.is_none()
diff --git a/crates/delta/src/routes/users/send_friend_request.rs b/crates/delta/src/routes/users/send_friend_request.rs
index 1c9a6d1e7..156532aa6 100644
--- a/crates/delta/src/routes/users/send_friend_request.rs
+++ b/crates/delta/src/routes/users/send_friend_request.rs
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
 /// # User Lookup Information
 #[derive(Serialize, Deserialize, JsonSchema)]
 pub struct DataSendFriendRequest {
+    /// Username and discriminator combo separated by #
     username: String,
 }
 
@@ -21,12 +22,16 @@ pub async fn req(
     user: User,
     data: Json<DataSendFriendRequest>,
 ) -> Result<Json<User>> {
-    let mut target = db.fetch_user_by_username(&data.username).await?;
+    if let Some((username, discriminator)) = data.username.split_once('#') {
+        let mut target = db.fetch_user_by_username(username, discriminator).await?;
 
-    if user.bot.is_some() || target.bot.is_some() {
-        return Err(Error::IsBot);
-    }
+        if user.bot.is_some() || target.bot.is_some() {
+            return Err(Error::IsBot);
+        }
 
-    user.add_friend(db, &mut target).await?;
-    Ok(Json(target.with_auto_perspective(db, &user).await))
+        user.add_friend(db, &mut target).await?;
+        Ok(Json(target.with_auto_perspective(db, &user).await))
+    } else {
+        Err(Error::InvalidProperty)
+    }
 }
diff --git a/crates/delta/src/util/regex.rs b/crates/delta/src/util/regex.rs
index 5ac6a7108..7c71e4b13 100644
--- a/crates/delta/src/util/regex.rs
+++ b/crates/delta/src/util/regex.rs
@@ -1,12 +1,17 @@
 use once_cell::sync::Lazy;
 use regex::Regex;
 
+/// Regex for valid display names
+///
+/// Block zero width space
+/// Block newline and carriage return
+pub static RE_DISPLAY_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^\u200B\n\r]+$").unwrap());
+
 /// Regex for valid usernames
 ///
 /// Block zero width space
 /// Block lookalike characters
-pub static RE_USERNAME: Lazy<Regex> =
-    Lazy::new(|| Regex::new(r"^[^\u200BА-Яа-яΑ-Ωα-ω@#:\n\r\[\]]+$").unwrap());
+pub static RE_USERNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap());
 
 /// Regex for valid emoji names
 ///
diff --git a/crates/quark/src/impl/dummy/users/user.rs b/crates/quark/src/impl/dummy/users/user.rs
index 9b3bfd2a8..8a0c7f6a7 100644
--- a/crates/quark/src/impl/dummy/users/user.rs
+++ b/crates/quark/src/impl/dummy/users/user.rs
@@ -9,11 +9,12 @@ impl AbstractUser for DummyDb {
         Ok(User {
             id: id.into(),
             username: "username".into(),
+            discriminator: "0000".into(),
             ..Default::default()
         })
     }
 
-    async fn fetch_user_by_username(&self, username: &str) -> Result<User> {
+    async fn fetch_user_by_username(&self, username: &str, _discriminator: &str) -> Result<User> {
         self.fetch_user(username).await
     }
 
@@ -45,8 +46,8 @@ impl AbstractUser for DummyDb {
         Ok(vec![self.fetch_user("id").await.unwrap()])
     }
 
-    async fn is_username_taken(&self, _username: &str) -> Result<bool> {
-        Ok(false)
+    async fn fetch_discriminators_in_use(&self, _username: &str) -> Result<Vec<String>> {
+        Ok(vec![])
     }
 
     async fn fetch_mutual_user_ids(&self, _user_a: &str, _user_b: &str) -> Result<Vec<String>> {
diff --git a/crates/quark/src/impl/generic/users/user.rs b/crates/quark/src/impl/generic/users/user.rs
index 3355a5c77..2d4c96065 100644
--- a/crates/quark/src/impl/generic/users/user.rs
+++ b/crates/quark/src/impl/generic/users/user.rs
@@ -8,7 +8,10 @@ use crate::{perms, Database, Error, Result};
 
 use futures::try_join;
 use impl_ops::impl_op_ex_commutative;
+use once_cell::sync::Lazy;
+use rand::seq::SliceRandom;
 use revolt_presence::filter_online;
+use std::collections::HashSet;
 use std::ops;
 
 impl_op_ex_commutative!(+ |a: &i32, b: &Badges| -> i32 { *a | *b as i32 });
@@ -169,15 +172,7 @@ impl User {
     }
 
     /// Sanitise and validate a username can be used
-    pub async fn validate_username(db: &Database, username: String) -> Result<String> {
-        // Trim surrounding spaces
-        let username = username.trim().to_string();
-
-        // Make sure username is still at least 3 characters
-        if username.len() < 2 {
-            return Err(Error::InvalidUsername);
-        }
-
+    pub fn validate_username(username: String) -> Result<String> {
         // Copy the username for validation
         let username_lowercase = username.to_lowercase();
 
@@ -199,25 +194,74 @@ impl User {
             }
         }
 
-        // Make sure the username isn't taken
-        if db.is_username_taken(&username).await? {
+        Ok(username)
+    }
+
+    // Find a free discriminator for a given username
+    pub async fn find_discriminator(
+        db: &Database,
+        username: &str,
+        preferred: Option<String>,
+    ) -> Result<String> {
+        let search_space: &HashSet<String> = &DISCRIMINATOR_SEARCH_SPACE_QUARK;
+        let used_discriminators: HashSet<String> = db
+            .fetch_discriminators_in_use(username)
+            .await?
+            .into_iter()
+            .collect();
+
+        let available_discriminators: Vec<&String> =
+            search_space.difference(&used_discriminators).collect();
+
+        if available_discriminators.is_empty() {
             return Err(Error::UsernameTaken);
         }
 
-        Ok(username)
+        if let Some(preferred) = preferred {
+            if available_discriminators.contains(&&preferred) {
+                return Ok(preferred);
+            }
+        }
+
+        let mut rng = rand::thread_rng();
+        Ok(available_discriminators
+            .choose(&mut rng)
+            .expect("we can assert this has an element")
+            .to_string())
     }
 
     /// Update a user's username
     pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
-        self.update(
-            db,
-            PartialUser {
-                username: Some(User::validate_username(db, username).await?),
-                ..Default::default()
-            },
-            vec![],
-        )
-        .await
+        let username = User::validate_username(username)?;
+        if self.username.to_lowercase() == username.to_lowercase() {
+            self.update(
+                db,
+                PartialUser {
+                    username: Some(username),
+                    ..Default::default()
+                },
+                vec![],
+            )
+            .await
+        } else {
+            self.update(
+                db,
+                PartialUser {
+                    discriminator: Some(
+                        User::find_discriminator(
+                            db,
+                            &username,
+                            Some(self.discriminator.to_string()),
+                        )
+                        .await?,
+                    ),
+                    username: Some(username),
+                    ..Default::default()
+                },
+                vec![],
+            )
+            .await
+        }
     }
 
     /// Apply a certain relationship between two users
@@ -407,3 +451,15 @@ impl User {
         }
     }
 }
+
+pub static DISCRIMINATOR_SEARCH_SPACE_QUARK: Lazy<HashSet<String>> = Lazy::new(|| {
+    let mut set = (2..9999)
+        .map(|v| format!("{:0>4}", v))
+        .collect::<HashSet<String>>();
+
+    for discrim in [1111, 2222, 3333, 4444, 5555, 6666, 7777, 8888, 9999] {
+        set.remove(&discrim.to_string());
+    }
+
+    set.into_iter().collect()
+});
diff --git a/crates/quark/src/impl/mongo/users/user.rs b/crates/quark/src/impl/mongo/users/user.rs
index bbfe84f12..ee7ba1924 100644
--- a/crates/quark/src/impl/mongo/users/user.rs
+++ b/crates/quark/src/impl/mongo/users/user.rs
@@ -9,14 +9,16 @@ use crate::{AbstractUser, Error, Result};
 
 use super::super::MongoDb;
 
-static FIND_USERNAME_OPTIONS: Lazy<FindOneOptions> = Lazy::new(|| FindOneOptions::builder()
-    .collation(
-        Collation::builder()
-            .locale("en")
-            .strength(CollationStrength::Secondary)
-            .build()
-    )
-    .build());
+static FIND_USERNAME_OPTIONS: Lazy<FindOneOptions> = Lazy::new(|| {
+    FindOneOptions::builder()
+        .collation(
+            Collation::builder()
+                .locale("en")
+                .strength(CollationStrength::Secondary)
+                .build(),
+        )
+        .build()
+});
 
 static COL: &str = "users";
 
@@ -26,11 +28,12 @@ impl AbstractUser for MongoDb {
         self.find_one_by_id(COL, id).await
     }
 
-    async fn fetch_user_by_username(&self, username: &str) -> Result<User> {
+    async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User> {
         self.find_one_with_options(
             COL,
             doc! {
-                "username": username
+                "username": username,
+                "discriminator": discriminator
             },
             FIND_USERNAME_OPTIONS.clone(),
         )
@@ -106,13 +109,34 @@ impl AbstractUser for MongoDb {
         Ok(users)
     }
 
-    async fn is_username_taken(&self, username: &str) -> Result<bool> {
-        // ! FIXME: move this up to generic
-        match self.fetch_user_by_username(username).await {
-            Ok(_) => Ok(true),
-            Err(Error::NotFound) => Ok(false),
-            Err(error) => Err(error),
-        }
+    async fn fetch_discriminators_in_use(&self, username: &str) -> Result<Vec<String>> {
+        Ok(self
+            .col::<Document>(COL)
+            .find(
+                doc! {
+                    "username": username
+                },
+                FindOptions::builder()
+                    .collation(
+                        Collation::builder()
+                            .locale("en")
+                            .strength(CollationStrength::Secondary)
+                            .build(),
+                    )
+                    .projection(doc! { "_id": 0, "discriminator": 1 })
+                    .build(),
+            )
+            .await
+            .map_err(|_| Error::DatabaseError {
+                operation: "find",
+                with: "users",
+            })?
+            .filter_map(|s| async { s.ok() })
+            .collect::<Vec<Document>>()
+            .await
+            .into_iter()
+            .filter_map(|x| x.get_str("discriminator").ok().map(|x| x.to_string()))
+            .collect::<Vec<String>>())
     }
 
     async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result<Vec<String>> {
diff --git a/crates/quark/src/models/users/user.rs b/crates/quark/src/models/users/user.rs
index 1ea03fd9f..7875e037a 100644
--- a/crates/quark/src/models/users/user.rs
+++ b/crates/quark/src/models/users/user.rs
@@ -128,6 +128,10 @@ pub struct User {
     pub id: String,
     /// Username
     pub username: String,
+    /// Discriminator
+    pub discriminator: String,
+    /// Display name
+    pub display_name: String,
     #[serde(skip_serializing_if = "Option::is_none")]
     /// Avatar attachment
     pub avatar: Option<File>,
diff --git a/crates/quark/src/traits/users/user.rs b/crates/quark/src/traits/users/user.rs
index 820b19410..b0b5f8c8e 100644
--- a/crates/quark/src/traits/users/user.rs
+++ b/crates/quark/src/traits/users/user.rs
@@ -7,7 +7,7 @@ pub trait AbstractUser: Sync + Send {
     async fn fetch_user(&self, id: &str) -> Result<User>;
 
     /// Fetch a user from the database by their username
-    async fn fetch_user_by_username(&self, username: &str) -> Result<User>;
+    async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User>;
 
     /// Fetch a user from the database by their session token
     async fn fetch_user_by_token(&self, token: &str) -> Result<User>;
@@ -29,8 +29,8 @@ pub trait AbstractUser: Sync + Send {
     /// Fetch multiple users by their ids
     async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>;
 
-    /// Check whether a username is already in use by another user
-    async fn is_username_taken(&self, username: &str) -> Result<bool>;
+    /// Fetch all discriminators in use for a username
+    async fn fetch_discriminators_in_use(&self, username: &str) -> Result<Vec<String>>;
 
     /// Fetch ids of users that both users are friends with
     async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result<Vec<String>>;


Test patch: