mark_deleted: atomic last-admin guard; review minors
This commit is contained in:
@@ -169,15 +169,32 @@ impl UserRepository for SqliteUserRepository {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let stamped =
|
||||
sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
|
||||
.bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.bind(&id_str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
// The last-admin guard lives in this single statement, so two admins
|
||||
// deleting each other concurrently cannot both pass it: the write lock
|
||||
// serialises them and the loser sees the winner's tombstone.
|
||||
let stamped = sqlx::query(
|
||||
"UPDATE users SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL
|
||||
AND (role != 'admin' OR EXISTS (
|
||||
SELECT 1 FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?2))",
|
||||
)
|
||||
.bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.bind(&id_str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
if stamped.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound("User not found".into()));
|
||||
let live: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users WHERE id = ? AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
return Err(if live == 0 {
|
||||
DomainError::NotFound("User not found".into())
|
||||
} else {
|
||||
DomainError::Conflict("the last active admin cannot be deleted".into())
|
||||
});
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO retired_usernames (username) SELECT username FROM users WHERE id = ?",
|
||||
|
||||
Reference in New Issue
Block a user