mark_deleted: atomic last-admin guard; review minors

This commit is contained in:
2026-10-26 02:16:35 +01:00
parent 0b5d18c4ee
commit 9a03de8956
5 changed files with 212 additions and 15 deletions

View File

@@ -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 = ?",