@@ -944,6 +944,8 @@ impl DB {
944944
945945 /// Ensure a user exists in this cohort DB by `global_user_id`.
946946 /// Used in multi-cohort mode where the global DB tracks the user identity.
947+ /// On first creation, if `requested_name` is already taken in this cohort, the
948+ /// smallest `-N` suffix (N >= 2) that's still available is appended.
947949 #[ instrument( err, skip( self ) ) ]
948950 pub async fn ensure_user_created_by_global_id (
949951 & self ,
@@ -953,7 +955,6 @@ impl DB {
953955 ) -> SqlxResult < ValidationResult < EnsureUserCreatedSuccess > > {
954956 let balance = Text ( initial_balance) ;
955957
956- // First try to find user by global_user_id
957958 let existing_user = sqlx:: query!(
958959 r#"
959960 SELECT id AS "id!", name
@@ -972,23 +973,12 @@ impl DB {
972973 } ) ) ;
973974 }
974975
975- // Check for name conflicts
976- let conflicting_account = sqlx:: query!(
977- r#"
978- SELECT id
979- FROM account
980- WHERE name = ? AND (global_user_id != ? OR global_user_id IS NULL)
981- "# ,
982- requested_name,
983- global_user_id
984- )
985- . fetch_optional ( & self . pool )
986- . await ?;
987-
988- let final_name = if conflicting_account. is_some ( ) {
989- format ! ( "{requested_name}-g{global_user_id}" )
990- } else {
991- requested_name. to_string ( )
976+ let final_name = match self
977+ . suggest_cohort_account_name ( requested_name, None )
978+ . await ?
979+ {
980+ Ok ( name) => name,
981+ Err ( failure) => return Ok ( Err ( failure) ) ,
992982 } ;
993983
994984 let id = sqlx:: query_scalar!(
@@ -1009,6 +999,101 @@ impl DB {
1009999 } ) )
10101000 }
10111001
1002+ /// Probe `base`, then `base-2`, `base-3`, ..., up to `base-999`, returning the
1003+ /// first cohort-local `account.name` that is still available. When
1004+ /// `exclude_account_id` is `Some(id)`, rows with `account.id = id` do not count
1005+ /// as a conflict — use this so a user renaming themselves doesn't race their
1006+ /// own current row.
1007+ ///
1008+ /// # Errors
1009+ /// Returns a database error, or `NameSuffixExhausted` if `base-2..=base-999`
1010+ /// are all taken.
1011+ pub async fn suggest_cohort_account_name (
1012+ & self ,
1013+ base : & str ,
1014+ exclude_account_id : Option < i64 > ,
1015+ ) -> SqlxResult < ValidationResult < String > > {
1016+ for suffix in std:: iter:: once ( 0u32 ) . chain ( 2 ..=999u32 ) {
1017+ let candidate = if suffix == 0 {
1018+ base. to_string ( )
1019+ } else {
1020+ format ! ( "{base}-{suffix}" )
1021+ } ;
1022+ let row = sqlx:: query_scalar!(
1023+ r#"
1024+ SELECT id AS "id!"
1025+ FROM account
1026+ WHERE name = ?
1027+ LIMIT 1
1028+ "# ,
1029+ candidate
1030+ )
1031+ . fetch_optional ( & self . pool )
1032+ . await ?;
1033+ match ( row, exclude_account_id) {
1034+ ( None , _) => return Ok ( Ok ( candidate) ) ,
1035+ ( Some ( id) , Some ( excluded) ) if id == excluded => return Ok ( Ok ( candidate) ) ,
1036+ _ => { }
1037+ }
1038+ }
1039+ Ok ( Err ( ValidationFailure :: NameSuffixExhausted ) )
1040+ }
1041+
1042+ /// Look up a user account by `global_user_id`. Returns `(id, name)` of the
1043+ /// caller's cohort-local account, or `None` if they don't have one yet.
1044+ ///
1045+ /// # Errors
1046+ /// Returns a database error.
1047+ pub async fn get_user_account_by_global_user_id (
1048+ & self ,
1049+ global_user_id : i64 ,
1050+ ) -> SqlxResult < Option < ( i64 , String ) > > {
1051+ let row = sqlx:: query!(
1052+ r#"
1053+ SELECT id AS "id!", name
1054+ FROM account
1055+ WHERE global_user_id = ?
1056+ "# ,
1057+ global_user_id
1058+ )
1059+ . fetch_optional ( & self . pool )
1060+ . await ?;
1061+ Ok ( row. map ( |r| ( r. id , r. name ) ) )
1062+ }
1063+
1064+ /// Rename an existing account. Returns `NameAlreadyExists` if the target name
1065+ /// would violate the cohort-local `account.name UNIQUE` constraint.
1066+ ///
1067+ /// # Errors
1068+ /// Returns a database error, or `NameAlreadyExists` via `ValidationResult`.
1069+ pub async fn rename_user_account (
1070+ & self ,
1071+ account_id : i64 ,
1072+ new_name : & str ,
1073+ ) -> SqlxResult < ValidationResult < ( ) > > {
1074+ let result = sqlx:: query!(
1075+ r#"
1076+ UPDATE account
1077+ SET name = ?
1078+ WHERE id = ?
1079+ "# ,
1080+ new_name,
1081+ account_id,
1082+ )
1083+ . execute ( & self . pool )
1084+ . await ;
1085+
1086+ match result {
1087+ Ok ( _) => Ok ( Ok ( ( ) ) ) ,
1088+ Err ( sqlx:: Error :: Database ( db_err) )
1089+ if db_err. message ( ) . contains ( "UNIQUE constraint failed" ) =>
1090+ {
1091+ Ok ( Err ( ValidationFailure :: NameAlreadyExists ) )
1092+ }
1093+ Err ( e) => Err ( e) ,
1094+ }
1095+ }
1096+
10121097 /// # Errors
10131098 /// Fails is there's a database error
10141099 pub async fn get_portfolio ( & self , account_id : i64 ) -> SqlxResult < Option < Portfolio > > {
@@ -4544,6 +4629,7 @@ pub enum ValidationFailure {
45444629 AlreadyOwner ,
45454630 EmptyName ,
45464631 NameAlreadyExists ,
4632+ NameSuffixExhausted ,
45474633 InvalidAccountColor ,
45484634 InvalidOwner ,
45494635 OwnerInDifferentUniverse ,
@@ -4618,6 +4704,7 @@ impl ValidationFailure {
46184704 Self :: AlreadyOwner => "Already owner" ,
46194705 Self :: EmptyName => "Account name cannot be empty" ,
46204706 Self :: NameAlreadyExists => "Account name already exists" ,
4707+ Self :: NameSuffixExhausted => "Could not find an available numeric suffix for this name" ,
46214708 Self :: InvalidAccountColor => "Account color must be a hex value like #aabbcc" ,
46224709 Self :: InvalidOwner => "Invalid owner" ,
46234710 Self :: OwnerInDifferentUniverse => "Owner must be in universe 0 or the same universe" ,
0 commit comments