feat: add is_admin to User, count_users, ProviderConfigRepository trait, admin migration

This commit is contained in:
2026-03-16 03:22:00 +01:00
parent b35054f23e
commit d80d4e9741
6 changed files with 65 additions and 14 deletions

View File

@@ -22,6 +22,7 @@ pub struct User {
pub subject: String, pub subject: String,
pub email: Email, pub email: Email,
pub password_hash: Option<String>, pub password_hash: Option<String>,
pub is_admin: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -32,6 +33,7 @@ impl User {
subject: subject.into(), subject: subject.into(),
email, email,
password_hash: None, password_hash: None,
is_admin: false,
created_at: Utc::now(), created_at: Utc::now(),
} }
} }
@@ -41,6 +43,7 @@ impl User {
subject: impl Into<String>, subject: impl Into<String>,
email: Email, email: Email,
password_hash: Option<String>, password_hash: Option<String>,
is_admin: bool,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
) -> Self { ) -> Self {
Self { Self {
@@ -48,6 +51,7 @@ impl User {
subject: subject.into(), subject: subject.into(),
email, email,
password_hash, password_hash,
is_admin,
created_at, created_at,
} }
} }
@@ -58,6 +62,7 @@ impl User {
subject: format!("local|{}", Uuid::new_v4()), subject: format!("local|{}", Uuid::new_v4()),
email, email,
password_hash: Some(password_hash.into()), password_hash: Some(password_hash.into()),
is_admin: false,
created_at: Utc::now(), created_at: Utc::now(),
} }
} }

View File

@@ -39,6 +39,24 @@ pub trait UserRepository: Send + Sync {
/// Delete a user by their ID /// Delete a user by their ID
async fn delete(&self, id: Uuid) -> DomainResult<()>; async fn delete(&self, id: Uuid) -> DomainResult<()>;
/// Count total number of users (used for first-user admin promotion)
async fn count_users(&self) -> DomainResult<u64>;
}
#[derive(Debug, Clone)]
pub struct ProviderConfigRow {
pub provider_type: String,
pub config_json: String,
pub enabled: bool,
pub updated_at: String,
}
#[async_trait]
pub trait ProviderConfigRepository: Send + Sync {
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>>;
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()>;
async fn delete(&self, provider_type: &str) -> DomainResult<()>;
} }
/// Repository port for `Channel` persistence. /// Repository port for `Channel` persistence.

View File

@@ -31,7 +31,10 @@ impl UserService {
} }
let email = Email::try_from(email)?; let email = Email::try_from(email)?;
let user = User::new(subject, email); let mut user = User::new(subject, email);
if self.user_repository.count_users().await? == 0 {
user.is_admin = true;
}
self.user_repository.save(&user).await?; self.user_repository.save(&user).await?;
Ok(user) Ok(user)
} }
@@ -53,7 +56,10 @@ impl UserService {
password_hash: &str, password_hash: &str,
) -> DomainResult<User> { ) -> DomainResult<User> {
let email = Email::try_from(email)?; let email = Email::try_from(email)?;
let user = User::new_local(email, password_hash); let mut user = User::new_local(email, password_hash);
if self.user_repository.count_users().await? == 0 {
user.is_admin = true;
}
self.user_repository.save(&user).await?; self.user_repository.save(&user).await?;
Ok(user) Ok(user)
} }

View File

@@ -10,6 +10,7 @@ pub(super) struct UserRow {
pub subject: String, pub subject: String,
pub email: String, pub email: String,
pub password_hash: Option<String>, pub password_hash: Option<String>,
pub is_admin: i64,
pub created_at: String, pub created_at: String,
} }
@@ -36,6 +37,7 @@ impl TryFrom<UserRow> for User {
row.subject, row.subject,
email, email,
row.password_hash, row.password_hash,
row.is_admin != 0,
created_at, created_at,
)) ))
} }

View File

@@ -22,7 +22,7 @@ impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> { async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
let id_str = id.to_string(); let id_str = id.to_string();
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = $1", "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE id = $1",
) )
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -34,7 +34,7 @@ impl UserRepository for PostgresUserRepository {
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> { async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = $1", "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE subject = $1",
) )
.bind(subject) .bind(subject)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -46,7 +46,7 @@ impl UserRepository for PostgresUserRepository {
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> { async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = $1", "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE email = $1",
) )
.bind(email) .bind(email)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -62,18 +62,20 @@ impl UserRepository for PostgresUserRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO users (id, subject, email, password_hash, created_at) INSERT INTO users (id, subject, email, password_hash, is_admin, created_at)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject, subject = excluded.subject,
email = excluded.email, email = excluded.email,
password_hash = excluded.password_hash password_hash = excluded.password_hash,
is_admin = excluded.is_admin
"#, "#,
) )
.bind(&id) .bind(&id)
.bind(&user.subject) .bind(&user.subject)
.bind(user.email.as_ref()) .bind(user.email.as_ref())
.bind(&user.password_hash) .bind(&user.password_hash)
.bind(user.is_admin)
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -99,4 +101,12 @@ impl UserRepository for PostgresUserRepository {
Ok(()) Ok(())
} }
async fn count_users(&self) -> DomainResult<u64> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(count as u64)
}
} }

View File

@@ -22,7 +22,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> { async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
let id_str = id.to_string(); let id_str = id.to_string();
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = ?", "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE id = ?",
) )
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -34,7 +34,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> { async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = ?", "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE subject = ?",
) )
.bind(subject) .bind(subject)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -46,7 +46,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> { async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
let row: Option<UserRow> = sqlx::query_as( let row: Option<UserRow> = sqlx::query_as(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = ?", "SELECT id, subject, email, password_hash, is_admin, created_at FROM users WHERE email = ?",
) )
.bind(email) .bind(email)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -62,18 +62,20 @@ impl UserRepository for SqliteUserRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO users (id, subject, email, password_hash, created_at) INSERT INTO users (id, subject, email, password_hash, is_admin, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject, subject = excluded.subject,
email = excluded.email, email = excluded.email,
password_hash = excluded.password_hash password_hash = excluded.password_hash,
is_admin = excluded.is_admin
"#, "#,
) )
.bind(&id) .bind(&id)
.bind(&user.subject) .bind(&user.subject)
.bind(user.email.as_ref()) .bind(user.email.as_ref())
.bind(&user.password_hash) .bind(&user.password_hash)
.bind(user.is_admin as i64)
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -100,6 +102,14 @@ impl UserRepository for SqliteUserRepository {
Ok(()) Ok(())
} }
async fn count_users(&self) -> DomainResult<u64> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(count as u64)
}
} }
#[cfg(test)] #[cfg(test)]