feat: user tombstone and retired usernames
This commit is contained in:
@@ -66,11 +66,13 @@ const USER_COLS: &str = "id, email, username, password_hash, role, display_name,
|
||||
impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let email_str = email.value();
|
||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
|
||||
.bind(email_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {USER_COLS} FROM users WHERE email = ? AND deleted_at IS NULL"
|
||||
))
|
||||
.bind(email_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
@@ -79,11 +81,13 @@ impl UserRepository for SqliteUserRepository {
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let username_str = username.value();
|
||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?"))
|
||||
.bind(username_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {USER_COLS} FROM users WHERE username = ? AND deleted_at IS NULL"
|
||||
))
|
||||
.bind(username_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
@@ -91,12 +95,12 @@ impl UserRepository for SqliteUserRepository {
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
if self.find_by_email(user.email()).await?.is_some() {
|
||||
if self.row_exists("email", user.email().value()).await? {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Email already registered".into(),
|
||||
));
|
||||
}
|
||||
if self.find_by_username(user.username()).await?.is_some() {
|
||||
if self.row_exists("username", user.username().value()).await? {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Username already taken".into(),
|
||||
));
|
||||
@@ -130,31 +134,86 @@ impl UserRepository for SqliteUserRepository {
|
||||
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NULL"
|
||||
))
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
self.load_with_fields(&id_str, row).await
|
||||
}
|
||||
|
||||
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NOT NULL"
|
||||
))
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
self.load_with_fields(&id_str, row).await
|
||||
}
|
||||
|
||||
async fn mark_deleted(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
events: &[domain::events::DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let id_str = user_id.value().to_string();
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let field_rows = sqlx::query(
|
||||
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
||||
let stamped =
|
||||
sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.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()));
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO retired_usernames (username) SELECT username FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
crate::shared::enqueue_events(&mut tx, events).await?;
|
||||
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
let profile_fields = field_rows
|
||||
.iter()
|
||||
.map(|f| ProfileField {
|
||||
name: f.get("name"),
|
||||
value: f.get("value"),
|
||||
})
|
||||
.collect();
|
||||
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError> {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT EXISTS(SELECT 1 FROM retired_usernames WHERE username = ?)",
|
||||
)
|
||||
.bind(username.value())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map(|n| n != 0)
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
Self::row_to_user(&r, profile_fields).map(Some)
|
||||
async fn count_active_admins(&self) -> Result<u64, DomainError> {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM users WHERE role = 'admin' AND deleted_at IS NULL",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map(|n| n as u64)
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
@@ -193,6 +252,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
u.avatar_path
|
||||
FROM users u
|
||||
LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL
|
||||
WHERE u.deleted_at IS NULL
|
||||
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
|
||||
ORDER BY u.email ASC"#,
|
||||
)
|
||||
@@ -204,3 +264,44 @@ impl UserRepository for SqliteUserRepository {
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteUserRepository {
|
||||
/// Uniqueness check over every row, tombstones included: a deleted
|
||||
/// account keeps its email and username until the purge removes the row.
|
||||
async fn row_exists(&self, column: &str, value: &str) -> Result<bool, DomainError> {
|
||||
sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)"
|
||||
))
|
||||
.bind(value)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map(|n| n != 0)
|
||||
.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn load_with_fields(
|
||||
&self,
|
||||
id_str: &str,
|
||||
row: Option<sqlx::sqlite::SqliteRow>,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let field_rows = sqlx::query(
|
||||
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
||||
)
|
||||
.bind(id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let profile_fields = field_rows
|
||||
.iter()
|
||||
.map(|f| ProfileField {
|
||||
name: f.get("name"),
|
||||
value: f.get("value"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::row_to_user(&r, profile_fields).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user