refactor: introduce user changeset

This commit is contained in:
Max Hohlfeld 2025-01-31 14:50:36 +01:00
parent 128fca8138
commit cd20457f67
13 changed files with 300 additions and 270 deletions

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE user_ SET locked = $1 WHERE id = $2;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Int4"
]
},
"nullable": []
},
"hash": "348365fd1e76ebfcfa065d1ea7e22cd7cbcd2e981f75fce75f29f1c4fbfc3df5"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE user_ SET receiveNotifications = $1 WHERE id = $2;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Int4"
]
},
"nullable": []
},
"hash": "3e246c54d31804140272a6fc2e3c241c17b086ff219d6084684a0f2b7c31eeed"
}

View File

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE user_ SET password = $1, salt = $2 WHERE id = $3;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "868bbdcb65f0ee862f221b7e3d1a4f4dbc4d818315d1713a110c4ad7acd09e3e"
}

View File

@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE user_ SET name = $1, email = $2, role = $3, function = $4, areaId = $5 WHERE id = $6;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "role",
"kind": {
"Enum": [
"staff",
"areamanager",
"admin"
]
}
}
},
{
"Custom": {
"name": "function",
"kind": {
"Enum": [
"posten",
"fuehrungsassistent",
"wachhabender"
]
}
}
},
"Int4",
"Int4"
]
},
"nullable": []
},
"hash": "fd2f782d28612d969aa20eb35ea8da4bfdba6f059dbd45b510122210807ac5b6"
}

16
Cargo.lock generated
View File

@ -1245,6 +1245,18 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dummy"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3ee4e39146145f7dd28e6c85ffdce489d93c0d9c88121063b8aacabbd9858d2"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.96",
]
[[package]]
name = "either"
version = "1.13.0"
@ -1347,6 +1359,7 @@ checksum = "aef603df4ba9adbca6a332db7da6f614f21eafefbaf8e087844e452fdec152d0"
dependencies = [
"chrono",
"deunicode",
"dummy",
"rand",
]
@ -1549,6 +1562,8 @@ checksum = "f4bd1d7843e437a4caf1d6a9112ba1ee9635b09d909af22aa4e6ec01fe971e22"
dependencies = [
"compact_str",
"garde_derive",
"once_cell",
"regex",
"smallvec",
]
@ -1560,6 +1575,7 @@ checksum = "a0636cbdc03994db48fc89a0ce7765bd68d08bd8a7a68cb9a36bcde96790f413"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.96",
]

View File

@ -30,7 +30,7 @@ brass-macros = { path = "../macros" }
brass-config = { path = "../config" }
actix-http = "3.9.0"
rinja = "0.3.5"
garde = { version = "0.21.0", features = ["derive"] }
garde = { version = "0.21.0", features = ["derive", "email"] }
maud = "0.26.0"
[build-dependencies]
@ -40,5 +40,5 @@ change-detection = "1.2.0"
[dev-dependencies]
insta = { version = "1.41.1", features = ["yaml", "filters"] }
fake = { version = "3.0.1", features = ["chrono"]}
fake = { version = "3.0.1", features = ["chrono", "derive"]}
regex = "1.11.1"

View File

@ -123,20 +123,7 @@ async fn handle_password_change_request(
let (hash, salt) = generate_salt_and_hash_plain_password(password).unwrap();
User::update(
pool,
user_id,
None,
None,
Some(&hash),
Some(&salt),
None,
None,
None,
None,
None,
)
.await?;
User::update_password(pool, user_id, &hash, &salt).await?;
if let Some(token) = token {
token.delete(pool).await?;

View File

@ -1,24 +1,16 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use brass_macros::db_test;
use serde::{Deserialize, Serialize};
use garde::Validate;
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
endpoints::IdPath,
models::{Function, Role, User},
models::{Area, Role, User, UserChangeset},
utils::ApplicationError,
};
#[cfg(test)]
use crate::utils::test_helper::{test_post, DbTestContext, RequestConfig, StatusCode};
#[cfg(test)]
use fake::{
faker::{internet::raw::SafeEmail, name::raw::Name},
locales::EN,
Fake,
};
#[derive(Deserialize, Serialize)]
#[derive(Deserialize)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct EditUserForm {
email: String,
name: String,
@ -50,146 +42,100 @@ pub async fn post_edit(
return Err(ApplicationError::Unauthorized);
}
let mut changed = false;
let email = if user_in_db.email != form.email {
changed = true;
Some(form.email.as_str())
} else {
None
};
let name = if user_in_db.name != form.name {
changed = true;
Some(form.name.as_str())
} else {
None
};
let role = if user_in_db.role as u8 != form.role {
if let Ok(r) = Role::try_from(form.role) {
changed = true;
Some(r)
} else {
None
}
} else {
None
};
let function = if user_in_db.function as u8 != form.function {
if let Ok(f) = Function::try_from(form.function) {
changed = true;
Some(f)
} else {
None
}
} else {
None
};
let area = if user.role == Role::Admin
&& form.area.is_some()
&& user_in_db.area_id != form.area.unwrap()
{
changed = true;
Some(form.area.unwrap())
} else {
None
};
if changed {
User::update(
pool.get_ref(),
path.id,
email,
name,
None,
None,
role,
function,
area,
None,
None,
)
.await?;
let area_id = form.area.unwrap_or(user_in_db.area_id);
if Area::read_by_id(pool.get_ref(), area_id).await?.is_none() {
return Err(ApplicationError::Unauthorized);
}
let changeset = UserChangeset {
name: form.name.clone(),
email: form.email.clone(),
role: form.role.try_into()?,
function: form.function.try_into()?,
area_id,
};
if let Err(e) = changeset.validate() {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
};
User::update(pool.get_ref(), user_in_db.id, changeset).await?;
Ok(HttpResponse::Found()
.insert_header((LOCATION, "/users"))
.insert_header(("HX-LOCATION", "/users"))
.finish())
}
#[db_test]
async fn works_when_user_is_admin(context: &DbTestContext) {
User::create(
&context.db_pool,
&Name(EN).fake::<String>(),
&SafeEmail(EN).fake::<String>(),
Role::Staff,
Function::Posten,
1,
)
.await
.unwrap();
crate::models::Area::create(&context.db_pool, "Süd")
.await
.unwrap();
let app = context.app().await;
let config = RequestConfig {
uri: "/users/edit/1".to_string(),
role: Role::Admin,
function: crate::models::Function::Posten,
user_area: 1,
#[cfg(test)]
mod tests {
use super::*;
use crate::{models::*, utils::test_helper::*};
use brass_macros::db_test;
use fake::{
faker::{internet::en::SafeEmail, name::en::Name},
Fake, Faker,
};
let new_name: String = Name(EN).fake();
let new_mail: String = SafeEmail(EN).fake();
#[db_test]
async fn works_when_user_is_admin(context: &DbTestContext) {
User::create(&context.db_pool, Faker.fake()).await.unwrap();
let form = EditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
function: Function::Fuehrungsassistent as u8,
area: Some(2),
};
Area::create(&context.db_pool, "Süd").await.unwrap();
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::FOUND, response.status());
let app = context.app().await;
let config = RequestConfig {
uri: "/users/edit/1".to_string(),
role: Role::Admin,
function: Function::Posten,
user_area: 1,
};
let updated_user = User::read_by_id(&context.db_pool, 1)
.await
.unwrap()
.unwrap();
let new_name: String = Name().fake();
let new_mail: String = SafeEmail().fake();
assert_eq!(new_name, updated_user.name);
assert_eq!(new_mail, updated_user.email);
assert_eq!(Role::AreaManager, updated_user.role);
assert_eq!(Function::Fuehrungsassistent, updated_user.function);
assert_eq!(2, updated_user.area_id);
}
#[db_test]
async fn cant_edit_oneself(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig {
uri: "/users/edit/1".to_string(),
role: Role::Admin,
function: crate::models::Function::Posten,
user_area: 1,
};
let form = EditUserForm {
name: "".to_string(),
email: "".to_string(),
role: Role::AreaManager as u8,
function: Function::Fuehrungsassistent as u8,
area: Some(1),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
let form = EditUserForm {
name: new_name.clone(),
email: new_mail.clone(),
role: Role::AreaManager as u8,
function: Function::Fuehrungsassistent as u8,
area: Some(2),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::FOUND, response.status());
let updated_user = User::read_by_id(&context.db_pool, 1)
.await
.unwrap()
.unwrap();
assert_eq!(new_name, updated_user.name);
assert_eq!(new_mail, updated_user.email);
assert_eq!(Role::AreaManager, updated_user.role);
assert_eq!(Function::Fuehrungsassistent, updated_user.function);
assert_eq!(2, updated_user.area_id);
}
#[db_test]
async fn cant_edit_oneself(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig {
uri: "/users/edit/1".to_string(),
role: Role::Admin,
function: Function::Posten,
user_area: 1,
};
let form = EditUserForm {
name: "".to_string(),
email: "".to_string(),
role: Role::AreaManager as u8,
function: Function::Fuehrungsassistent as u8,
area: Some(1),
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
}
}

View File

@ -1,10 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
mail::Mailer,
models::{Function, Registration, Role, User},
models::{Function, Registration, Role, User, UserChangeset},
utils::ApplicationError,
};
@ -37,13 +38,21 @@ pub async fn post_new(
let role = Role::try_from(form.role)?;
let function = Function::try_from(form.function)?;
let id = User::create(
pool.get_ref(),
&form.name,
&form.email,
let changeset = UserChangeset {
name: form.name.clone(),
email: form.email.clone(),
role,
function,
area_id,
area_id
};
if let Err(e) = changeset.validate() {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
};
let id = User::create(
pool.get_ref(),
changeset
)
.await?;

View File

@ -25,28 +25,19 @@ pub async fn post(
}
let user = if user.id != path.id {
User::read_by_id(pool.get_ref(), path.id).await.unwrap().unwrap()
User::read_by_id(pool.get_ref(), path.id)
.await
.unwrap()
.unwrap()
} else {
user.into_inner()
};
match query.field.as_str() {
"locked" => {
User::update(
pool.get_ref(),
user.id,
None,
None,
None,
None,
None,
None,
None,
None,
Some(!user.locked),
)
.await
.unwrap();
User::update_locked(pool.get_ref(), user.id, !user.locked)
.await
.unwrap();
if !user.locked {
return HttpResponse::Ok().body(format!(
@ -79,18 +70,10 @@ pub async fn post(
}
}
"receiveNotifications" => {
User::update(
User::update_receive_notifications(
pool.get_ref(),
user.id,
None,
None,
None,
None,
None,
None,
None,
Some(!user.receive_notifications),
None,
!user.receive_notifications,
)
.await
.unwrap();

View File

@ -12,6 +12,7 @@ mod password_reset;
mod registration;
mod role;
mod user;
mod user_changeset;
mod vehicle;
mod vehicle_assignement;
@ -33,6 +34,7 @@ pub use password_reset::{NoneToken, PasswordReset, Token};
pub use registration::Registration;
pub use role::Role;
pub use user::User;
pub use user_changeset::UserChangeset;
pub use vehicle::Vehicle;
pub use vehicle_assignement::VehicleAssignement;

View File

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use super::{Area, Function, Result, Role};
use super::{Area, Function, Result, Role, UserChangeset};
#[derive(Clone, Debug)]
pub struct User {
@ -20,28 +20,22 @@ pub struct User {
}
impl User {
pub async fn create(
pool: &PgPool,
name: &str,
email: &str,
role: Role,
function: Function,
area_id: i32,
) -> Result<i32> {
pub async fn create(pool: &PgPool, changeset: UserChangeset) -> Result<i32> {
sqlx::query!(
r#"
INSERT INTO user_ (name, email, role, function, areaId)
VALUES ($1, $2, $3, $4, $5)
RETURNING id;
"#,
name,
email,
role as Role,
function as Function,
area_id
changeset.name,
changeset.email,
changeset.role as Role,
changeset.function as Function,
changeset.area_id
)
.fetch_one(pool)
.await.map(|r| r.id)
.await
.map(|r| r.id)
}
pub async fn create_with_password(
@ -54,8 +48,6 @@ impl User {
function: Function,
area_id: i32,
) -> Result<i32> {
sqlx::query!(
r#"
INSERT INTO user_ (name, email, password, salt, role, function, areaId)
@ -71,7 +63,8 @@ impl User {
area_id
)
.fetch_one(pool)
.await.map(|r| r.id)
.await
.map(|r| r.id)
}
pub async fn read_by_id(pool: &PgPool, id: i32) -> Result<Option<User>> {
@ -286,72 +279,55 @@ impl User {
Ok(result)
}
pub async fn update(
pub async fn update(pool: &PgPool, id: i32, changeset: UserChangeset) -> Result<()> {
sqlx::query!(
"UPDATE user_ SET name = $1, email = $2, role = $3, function = $4, areaId = $5 WHERE id = $6;",
changeset.name,
changeset.email,
changeset.role as Role,
changeset.function as Function,
changeset.area_id,
id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_password(pool: &PgPool, id: i32, password: &str, salt: &str) -> Result<()> {
sqlx::query!(
"UPDATE user_ SET password = $1, salt = $2 WHERE id = $3;",
password,
salt,
id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_locked(pool: &PgPool, id: i32, locked: bool) -> Result<()> {
sqlx::query!("UPDATE user_ SET locked = $1 WHERE id = $2;", locked, id)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_receive_notifications(
pool: &PgPool,
id: i32,
email: Option<&str>,
name: Option<&str>,
password: Option<&str>,
salt: Option<&str>,
role: Option<Role>,
function: Option<Function>,
area_id: Option<i32>,
receive_notifications: Option<bool>,
locked: Option<bool>,
receive_notifications: bool,
) -> Result<()> {
let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET ");
let mut separated = query_builder.separated(", ");
if let Some(email) = email {
separated.push("email = ");
separated.push_bind_unseparated(email);
}
if let Some(name) = name {
separated.push("name = ");
separated.push_bind_unseparated(name);
}
if let Some(password) = password {
separated.push("password = ");
separated.push_bind_unseparated(password);
}
if let Some(salt) = salt {
separated.push("salt = ");
separated.push_bind_unseparated(salt);
}
if let Some(role) = role {
separated.push("role = ");
separated.push_bind_unseparated(role as Role);
}
if let Some(function) = function {
separated.push("function = ");
separated.push_bind_unseparated(function as Function);
}
if let Some(area_id) = area_id {
separated.push("areaId = ");
separated.push_bind_unseparated(area_id);
}
if let Some(receive_notifications) = receive_notifications {
separated.push("receiveNotifications = ");
separated.push_bind_unseparated(receive_notifications);
}
if let Some(locked) = locked {
separated.push("locked = ");
separated.push_bind_unseparated(locked);
}
query_builder.push(" WHERE id = ");
query_builder.push_bind(id);
query_builder.push(";");
query_builder.build().execute(pool).await?;
sqlx::query!(
"UPDATE user_ SET receiveNotifications = $1 WHERE id = $2;",
receive_notifications,
id
)
.execute(pool)
.await?;
Ok(())
}

View File

@ -0,0 +1,24 @@
#[cfg(test)]
use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy};
use garde::Validate;
use super::{Function, Role};
#[derive(Validate)]
#[cfg_attr(test, derive(Dummy))]
#[garde(allow_unvalidated)]
pub struct UserChangeset {
#[cfg_attr(test, dummy(faker = "Name()"))]
pub name: String,
#[garde(email)]
#[cfg_attr(test, dummy(faker = "SafeEmail()"))]
pub email: String,
#[cfg_attr(test, dummy(expr = "Role::Staff"))]
pub role: Role,
#[cfg_attr(test, dummy(expr = "Function::Posten"))]
pub function: Function,
/// check before: must exist and user can create other user for this area
#[cfg_attr(test, dummy(expr = "1"))]
pub area_id: i32,
}