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

View File

@ -30,7 +30,7 @@ brass-macros = { path = "../macros" }
brass-config = { path = "../config" } brass-config = { path = "../config" }
actix-http = "3.9.0" actix-http = "3.9.0"
rinja = "0.3.5" rinja = "0.3.5"
garde = { version = "0.21.0", features = ["derive"] } garde = { version = "0.21.0", features = ["derive", "email"] }
maud = "0.26.0" maud = "0.26.0"
[build-dependencies] [build-dependencies]
@ -40,5 +40,5 @@ change-detection = "1.2.0"
[dev-dependencies] [dev-dependencies]
insta = { version = "1.41.1", features = ["yaml", "filters"] } 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" 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(); let (hash, salt) = generate_salt_and_hash_plain_password(password).unwrap();
User::update( User::update_password(pool, user_id, &hash, &salt).await?;
pool,
user_id,
None,
None,
Some(&hash),
Some(&salt),
None,
None,
None,
None,
None,
)
.await?;
if let Some(token) = token { if let Some(token) = token {
token.delete(pool).await?; token.delete(pool).await?;

View File

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

View File

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

View File

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

View File

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