124 lines
3.4 KiB
Rust
124 lines
3.4 KiB
Rust
//! 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<UserId, sqlx::Error> {
|
|
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<Option<User>, 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<Option<(User, String)>, 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<Vec<User>, 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<User, sqlx::Error> {
|
|
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,
|
|
})
|
|
}
|