feat(db): users table + repository (create/by_id/by_email/list), audited

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:37:43 +02:00
parent 9597a42eeb
commit f8ec2d7cf1
4 changed files with 233 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
//! 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>,
{
let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE 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.
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,
})
}