//! Users of this organization's instance. All SQL for users lives here. use domain::{ AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId, }; use serde_json::json; use sqlx::Row; use crate::audit; const ENTITY_TYPE: &str = "user"; const USER_COLUMNS: &str = "id, email, role"; /// Create a user and record a `created` audit entry (email + role only — never the /// password hash), both on `conn`. Pass a transaction connection. pub async fn create_user( conn: &mut sqlx::PgConnection, actor: AuditActor, new: &NewUser, ) -> Result { let id = UserId::new(); sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)") .bind(id.to_uuid()) .bind(new.email.as_str()) .bind(&new.password_hash) .bind(new.role.as_str()) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type: ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: vec![ FieldChange { field: "email".to_owned(), before: None, after: Some(json!(new.email.as_str())), }, FieldChange { field: "role".to_owned(), before: None, after: Some(json!(new.role.as_str())), }, ], }, ) .await?; Ok(id) } /// Fetch a user by id. pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1"); let row = sqlx::query(&sql) .bind(id.to_uuid()) .fetch_optional(executor) .await?; row.map(map_user).transpose() } /// Fetch a user and their password hash by (normalized) email, for login. pub async fn credentials_by_email<'e, E>( executor: E, email: &str, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { // Match the `lower(email)` unique index; `email` is already normalized by callers. let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE lower(email) = $1"); let row = sqlx::query(&sql) .bind(email) .fetch_optional(executor) .await?; match row { Some(row) => { let hash: String = row.try_get("password_hash")?; Ok(Some((map_user(row)?, hash))) } None => Ok(None), } } /// List all users, ordered by email. // TODO: add LIMIT/keyset pagination before exposing this via the API. pub async fn list_users<'e, E>(executor: E) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email"); let rows = sqlx::query(&sql).fetch_all(executor).await?; rows.into_iter().map(map_user).collect() } fn map_user(row: sqlx::postgres::PgRow) -> Result { let role_str: String = row.try_get("role")?; let role = Role::from_db(&role_str) .ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?; Ok(User { id: UserId::from_uuid(row.try_get("id")?), email: Email::from_db(row.try_get("email")?), role, }) }