From d80d4e9741b24b0978bbe32703d49d75e98527f1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 16 Mar 2026 03:22:00 +0100 Subject: [PATCH] feat: add is_admin to User, count_users, ProviderConfigRepository trait, admin migration --- k-tv-backend/domain/src/entities.rs | 5 +++++ k-tv-backend/domain/src/repositories.rs | 18 +++++++++++++++ k-tv-backend/domain/src/services/user.rs | 10 +++++++-- .../infra/src/user_repository/mapping.rs | 2 ++ .../infra/src/user_repository/postgres.rs | 22 ++++++++++++++----- .../infra/src/user_repository/sqlite.rs | 22 ++++++++++++++----- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index befe735..db9d8bd 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -22,6 +22,7 @@ pub struct User { pub subject: String, pub email: Email, pub password_hash: Option, + pub is_admin: bool, pub created_at: DateTime, } @@ -32,6 +33,7 @@ impl User { subject: subject.into(), email, password_hash: None, + is_admin: false, created_at: Utc::now(), } } @@ -41,6 +43,7 @@ impl User { subject: impl Into, email: Email, password_hash: Option, + is_admin: bool, created_at: DateTime, ) -> Self { Self { @@ -48,6 +51,7 @@ impl User { subject: subject.into(), email, password_hash, + is_admin, created_at, } } @@ -58,6 +62,7 @@ impl User { subject: format!("local|{}", Uuid::new_v4()), email, password_hash: Some(password_hash.into()), + is_admin: false, created_at: Utc::now(), } } diff --git a/k-tv-backend/domain/src/repositories.rs b/k-tv-backend/domain/src/repositories.rs index 470629a..433ee96 100644 --- a/k-tv-backend/domain/src/repositories.rs +++ b/k-tv-backend/domain/src/repositories.rs @@ -39,6 +39,24 @@ pub trait UserRepository: Send + Sync { /// Delete a user by their ID async fn delete(&self, id: Uuid) -> DomainResult<()>; + + /// Count total number of users (used for first-user admin promotion) + async fn count_users(&self) -> DomainResult; +} + +#[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>; + async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()>; + async fn delete(&self, provider_type: &str) -> DomainResult<()>; } /// Repository port for `Channel` persistence. diff --git a/k-tv-backend/domain/src/services/user.rs b/k-tv-backend/domain/src/services/user.rs index 98b05e4..78cd295 100644 --- a/k-tv-backend/domain/src/services/user.rs +++ b/k-tv-backend/domain/src/services/user.rs @@ -31,7 +31,10 @@ impl UserService { } 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?; Ok(user) } @@ -53,7 +56,10 @@ impl UserService { password_hash: &str, ) -> DomainResult { 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?; Ok(user) } diff --git a/k-tv-backend/infra/src/user_repository/mapping.rs b/k-tv-backend/infra/src/user_repository/mapping.rs index 581185f..bc72cac 100644 --- a/k-tv-backend/infra/src/user_repository/mapping.rs +++ b/k-tv-backend/infra/src/user_repository/mapping.rs @@ -10,6 +10,7 @@ pub(super) struct UserRow { pub subject: String, pub email: String, pub password_hash: Option, + pub is_admin: i64, pub created_at: String, } @@ -36,6 +37,7 @@ impl TryFrom for User { row.subject, email, row.password_hash, + row.is_admin != 0, created_at, )) } diff --git a/k-tv-backend/infra/src/user_repository/postgres.rs b/k-tv-backend/infra/src/user_repository/postgres.rs index 0a31d26..d51a141 100644 --- a/k-tv-backend/infra/src/user_repository/postgres.rs +++ b/k-tv-backend/infra/src/user_repository/postgres.rs @@ -22,7 +22,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_id(&self, id: Uuid) -> DomainResult> { let id_str = id.to_string(); let row: Option = 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) .fetch_optional(&self.pool) @@ -34,7 +34,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_subject(&self, subject: &str) -> DomainResult> { let row: Option = 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) .fetch_optional(&self.pool) @@ -46,7 +46,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_email(&self, email: &str) -> DomainResult> { let row: Option = 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) .fetch_optional(&self.pool) @@ -62,18 +62,20 @@ impl UserRepository for PostgresUserRepository { sqlx::query( r#" - INSERT INTO users (id, subject, email, password_hash, created_at) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO users (id, subject, email, password_hash, is_admin, created_at) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT(id) DO UPDATE SET subject = excluded.subject, email = excluded.email, - password_hash = excluded.password_hash + password_hash = excluded.password_hash, + is_admin = excluded.is_admin "#, ) .bind(&id) .bind(&user.subject) .bind(user.email.as_ref()) .bind(&user.password_hash) + .bind(user.is_admin) .bind(&created_at) .execute(&self.pool) .await @@ -99,4 +101,12 @@ impl UserRepository for PostgresUserRepository { Ok(()) } + + async fn count_users(&self) -> DomainResult { + 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) + } } diff --git a/k-tv-backend/infra/src/user_repository/sqlite.rs b/k-tv-backend/infra/src/user_repository/sqlite.rs index 0bf89cb..f52f8b4 100644 --- a/k-tv-backend/infra/src/user_repository/sqlite.rs +++ b/k-tv-backend/infra/src/user_repository/sqlite.rs @@ -22,7 +22,7 @@ impl UserRepository for SqliteUserRepository { async fn find_by_id(&self, id: Uuid) -> DomainResult> { let id_str = id.to_string(); let row: Option = 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) .fetch_optional(&self.pool) @@ -34,7 +34,7 @@ impl UserRepository for SqliteUserRepository { async fn find_by_subject(&self, subject: &str) -> DomainResult> { let row: Option = 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) .fetch_optional(&self.pool) @@ -46,7 +46,7 @@ impl UserRepository for SqliteUserRepository { async fn find_by_email(&self, email: &str) -> DomainResult> { let row: Option = 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) .fetch_optional(&self.pool) @@ -62,18 +62,20 @@ impl UserRepository for SqliteUserRepository { sqlx::query( r#" - INSERT INTO users (id, subject, email, password_hash, created_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO users (id, subject, email, password_hash, is_admin, created_at) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET subject = excluded.subject, email = excluded.email, - password_hash = excluded.password_hash + password_hash = excluded.password_hash, + is_admin = excluded.is_admin "#, ) .bind(&id) .bind(&user.subject) .bind(user.email.as_ref()) .bind(&user.password_hash) + .bind(user.is_admin as i64) .bind(&created_at) .execute(&self.pool) .await @@ -100,6 +102,14 @@ impl UserRepository for SqliteUserRepository { Ok(()) } + + async fn count_users(&self) -> DomainResult { + 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)]