diff --git a/migration.sql b/migration.sql index ad652b5..c2b105d 100644 --- a/migration.sql +++ b/migration.sql @@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS "family" ( CREATE TABLE IF NOT EXISTS "role" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, - "name" text NOT NULL UNIQUE + "name" text NOT NULL UNIQUE, + "cluster" text ); CREATE TABLE IF NOT EXISTS "user_role" ( @@ -220,3 +221,16 @@ CREATE TABLE IF NOT EXISTS "distance" ( "distance_in_km" integer NOT NULL ); + +CREATE UNIQUE INDEX one_role_per_group_per_user ON user_role +WHERE EXISTS ( + SELECT 1 + FROM role r1 + JOIN role r2 ON r1.id = user_role.role_id + WHERE r1."group" = r2."group" + AND r2.id IN ( + SELECT role_id + FROM user_role ur2 + WHERE ur2.user_id = user_role.user_id + ) +); diff --git a/src/model/family.rs b/src/model/family.rs index 894b082..6821d33 100644 --- a/src/model/family.rs +++ b/src/model/family.rs @@ -1,5 +1,7 @@ +use std::ops::DerefMut; + use serde::Serialize; -use sqlx::{sqlite::SqliteQueryResult, FromRow, SqlitePool}; +use sqlx::{sqlite::SqliteQueryResult, FromRow, Sqlite, SqlitePool, Transaction}; use super::user::User; @@ -22,6 +24,15 @@ impl Family { .unwrap() } + pub async fn insert_tx(db: &mut Transaction<'_, Sqlite>) -> i64 { + let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES") + .execute(db.deref_mut()) + .await + .unwrap(); + + result.last_insert_rowid() + } + pub async fn insert(db: &SqlitePool) -> i64 { let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES") .execute(db) diff --git a/src/model/role.rs b/src/model/role.rs index 8904860..df95157 100644 --- a/src/model/role.rs +++ b/src/model/role.rs @@ -7,11 +7,12 @@ use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; pub struct Role { pub(crate) id: i64, pub(crate) name: String, + pub(crate) cluster: Option, } impl Role { pub async fn all(db: &SqlitePool) -> Vec { - sqlx::query_as!(Role, "SELECT id, name FROM role") + sqlx::query_as!(Role, "SELECT id, name, cluster FROM role") .fetch_all(db) .await .unwrap() @@ -21,7 +22,7 @@ impl Role { sqlx::query_as!( Self, " -SELECT id, name +SELECT id, name, cluster FROM role WHERE id like ? ", @@ -31,12 +32,41 @@ WHERE id like ? .await .ok() } + pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, name: i32) -> Option { + sqlx::query_as!( + Self, + " +SELECT id, name, cluster +FROM role +WHERE id like ? + ", + name + ) + .fetch_one(db.deref_mut()) + .await + .ok() + } + + pub async fn find_by_cluster_tx(db: &mut Transaction<'_, Sqlite>, name: i32) -> Option { + sqlx::query_as!( + Self, + " +SELECT id, name, cluster +FROM role +WHERE cluster = ? + ", + name + ) + .fetch_one(db.deref_mut()) + .await + .ok() + } pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option { sqlx::query_as!( Self, " -SELECT id, name +SELECT id, name, cluster FROM role WHERE name like ? ", @@ -51,7 +81,7 @@ WHERE name like ? sqlx::query_as!( Self, " -SELECT id, name +SELECT id, name, cluster FROM role WHERE name like ? ", diff --git a/src/model/user.rs b/src/model/user.rs index c2bc150..9e9b8a3 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -490,6 +490,17 @@ ASKÖ Ruderverein Donau Linz", self.name), _ => true, } } + pub async fn has_membership_pdf_tx(&self, db: &mut Transaction<'_, Sqlite>) -> bool { + match sqlx::query_scalar!("SELECT membership_pdf FROM user WHERE id = ?", self.id) + .fetch_one(db.deref_mut()) + .await + .unwrap() + { + Some(a) if a.is_empty() => false, + None => false, + _ => true, + } + } pub async fn roles(&self, db: &SqlitePool) -> Vec { sqlx::query!( @@ -697,14 +708,16 @@ ORDER BY last_access DESC .is_ok() } - pub async fn update(&self, db: &SqlitePool, data: UserEditForm<'_>) { + pub async fn update(&self, db: &SqlitePool, data: UserEditForm<'_>) -> Result<(), String> { + let mut db = db.begin().await.map_err(|e| e.to_string())?; + let mut family_id = data.family_id; if family_id.is_some_and(|x| x == -1) { - family_id = Some(Family::insert(db).await) + family_id = Some(Family::insert_tx(&mut db).await) } - if !self.has_membership_pdf(db).await { + if !self.has_membership_pdf_tx(&mut db).await { if let Some(membership_pdf) = data.membership_pdf { let mut stream = membership_pdf.open().await.unwrap(); let mut buffer = Vec::new(); @@ -714,7 +727,7 @@ ORDER BY last_access DESC buffer, self.id ) - .execute(db) + .execute(db.deref_mut()) .await .unwrap(); //Okay, because we can only create a User of a valid id } @@ -735,28 +748,29 @@ ORDER BY last_access DESC family_id, self.id ) - .execute(db) + .execute(db.deref_mut()) .await .unwrap(); //Okay, because we can only create a User of a valid id // handle roles sqlx::query!("DELETE FROM user_role WHERE user_id = ?", self.id) - .execute(db) + .execute(db.deref_mut()) .await .unwrap(); for role_id in data.roles.into_keys() { - self.add_role( - db, - &Role::find_by_id(db, role_id.parse::().unwrap()) - .await - .unwrap(), - ) - .await; + let role = Role::find_by_id_tx(&mut db, role_id.parse::().unwrap()) + .await + .unwrap(); + self.add_role_tx(&mut db, &role).await?; } + + db.commit().await.map_err(|e| e.to_string())?; + + Ok(()) } - pub async fn add_role(&self, db: &SqlitePool, role: &Role) { + pub async fn add_role(&self, db: &SqlitePool, role: &Role) -> Result<(), String> { sqlx::query!( "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", self.id, @@ -764,7 +778,40 @@ ORDER BY last_access DESC ) .execute(db) .await - .unwrap(); + .map_err(|_| { + format!( + "User already has a role in the cluster '{}'", + role.cluster + .clone() + .expect("db trigger can't activate on empty string") + ) + })?; + + Ok(()) + } + + pub async fn add_role_tx( + &self, + db: &mut Transaction<'_, Sqlite>, + role: &Role, + ) -> Result<(), String> { + sqlx::query!( + "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", + self.id, + role.id + ) + .execute(db.deref_mut()) + .await + .map_err(|_| { + format!( + "User already has a role in the cluster '{}'", + role.cluster + .clone() + .expect("db trigger can't activate on empty string") + ) + })?; + + Ok(()) } pub async fn remove_role(&self, db: &SqlitePool, role: &Role) { diff --git a/src/tera/admin/user.rs b/src/tera/admin/user.rs index 9731054..3c7564d 100644 --- a/src/tera/admin/user.rs +++ b/src/tera/admin/user.rs @@ -200,7 +200,8 @@ async fn fees_paid( ) .await; user.add_role(db, &Role::find_by_name(db, "paid").await.unwrap()) - .await; + .await + .expect("paid role has no group"); } } @@ -305,9 +306,10 @@ async fn update( ); }; - user.update(db, data.into_inner()).await; - - Flash::success(Redirect::to("/admin/user"), "Successfully updated user") + match user.update(db, data.into_inner()).await { + Ok(_) => Flash::success(Redirect::to("/admin/user"), "Successfully updated user"), + Err(e) => Flash::error(Redirect::to("/admin/user"), e), + } } #[get("/user//membership")] @@ -394,7 +396,9 @@ async fn create_scheckbuch( // 4. Add 'scheckbuch' role let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); - user.add_role(db, &scheckbuch).await; + user.add_role(db, &scheckbuch) + .await + .expect("new user has no roles yet"); // 4. Send welcome mail (+ notification) user.send_welcome_email(db, &config.smtp_pw).await.unwrap(); @@ -434,10 +438,14 @@ async fn schnupper_to_scheckbuch( user.remove_role(db, &paid).await; let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); - user.add_role(db, &scheckbuch).await; + user.add_role(db, &scheckbuch) + .await + .expect("just removed 'schnupperant' thus can't have a role with that group"); if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await { - user.add_role(db, &no_einschreibgebuehr).await; + user.add_role(db, &no_einschreibgebuehr) + .await + .expect("role doesn't have a group"); } user.send_welcome_email(db, &config.smtp_pw).await.unwrap(); diff --git a/staging-diff.sql b/staging-diff.sql index 6fb21fc..09e7a30 100644 --- a/staging-diff.sql +++ b/staging-diff.sql @@ -3,3 +3,35 @@ INSERT INTO user(name) VALUES('Marie'); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz')); INSERT INTO user(name) VALUES('Philipp'); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Philipp'),(SELECT id FROM role where name = 'Donau Linz')); + +ALTER TABLE "role" ADD COLUMN "cluster" text; +CREATE TRIGGER prevent_multiple_roles_same_cluster +BEFORE INSERT ON user_role +BEGIN + SELECT CASE + WHEN EXISTS ( + SELECT 1 + FROM user_role ur + JOIN role r1 ON ur.role_id = r1.id + JOIN role r2 ON r1."cluster" = r2."cluster" + WHERE ur.user_id = NEW.user_id + AND r2.id = NEW.role_id + AND r1.id != NEW.role_id + ) + THEN RAISE(ABORT, 'User already has a role in this cluster') + END; +END; + + +UPDATE role SET 'cluster'='skill' WHERE id=2; +UPDATE role SET 'cluster'='membership_type' WHERE id=3; +UPDATE role SET 'cluster'='skill' WHERE id=5; +UPDATE role SET 'cluster'='skill' WHERE id=6; +UPDATE role SET 'cluster'='membership_type' WHERE id=7; +UPDATE role SET 'cluster'='financial' WHERE id=8; +UPDATE role SET 'cluster'='membership_type' WHERE id=9; +UPDATE role SET 'cluster'='membership_type' WHERE id=14; +UPDATE role SET 'cluster'='financial' WHERE id=17; +UPDATE role SET 'cluster'='financial' WHERE id=18; +UPDATE role SET 'cluster'='membership_type' WHERE id=20; +UPDATE role SET 'cluster'='membership_type' WHERE id=22;