Skip to content

Commit c5f4008

Browse files
authored
Fan out display-name rename to every cohort (ARB-513) (#390)
1 parent 3fedb38 commit c5f4008

11 files changed

Lines changed: 674 additions & 219 deletions

DEPLOY.md

Lines changed: 0 additions & 122 deletions
This file was deleted.

backend/.sqlx/query-46a626a49c0e079a63e534937feead6335e6f34afa2f9a867b6d08e671feb0d4.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/.sqlx/query-c4f5b6e260992d4e2df494d9ba3ad09b7ca596147544f719c4b09d6020e8a01b.json

Lines changed: 0 additions & 20 deletions
This file was deleted.

backend/.sqlx/query-de02634da7185857e9bbf779b3e35ecc50e5fbd111dcf036f88b636958974890.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/db.rs

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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",

backend/src/global_db.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,23 @@ impl GlobalDB {
287287
.await
288288
}
289289

290+
/// Get a global user by primary key.
291+
///
292+
/// # Errors
293+
/// Returns an error on database failure.
294+
pub async fn get_global_user_by_id(
295+
&self,
296+
id: i64,
297+
) -> Result<Option<GlobalUser>, sqlx::Error> {
298+
sqlx::query_as::<_, GlobalUser>(
299+
r"SELECT id, kinde_id, display_name, is_admin, is_kinde_admin, admin_grant, email
300+
FROM global_user WHERE id = ?",
301+
)
302+
.bind(id)
303+
.fetch_optional(&self.pool)
304+
.await
305+
}
306+
290307
/// Get all cohorts a user is a member of.
291308
///
292309
/// # Errors

0 commit comments

Comments
 (0)