From 3148d744e6000493f74ce1b81ede3e8cec531349 Mon Sep 17 00:00:00 2001 From: Philipp Hofer Date: Fri, 21 Nov 2025 10:32:59 +0100 Subject: [PATCH] yearly cleanup of roles; fixes #941 --- src/scheduled/mod.rs | 19 +++- src/scheduled/yearly_role_cleanup.rs | 158 +++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/scheduled/yearly_role_cleanup.rs diff --git a/src/scheduled/mod.rs b/src/scheduled/mod.rs index acae7df..9f2d4f5 100644 --- a/src/scheduled/mod.rs +++ b/src/scheduled/mod.rs @@ -1,5 +1,6 @@ mod waterlevel; mod weather; +mod yearly_role_cleanup; use std::time::Duration; @@ -13,7 +14,7 @@ pub fn schedule(db: &SqlitePool, config: &Config) { let db = db.clone(); let openweathermap_key = config.openweathermap_key.clone(); - tokio::task::spawn(async { + tokio::task::spawn(async move { if let Err(e) = waterlevel::update(&db).await { log::error!("Water level update error: {e}, trying again next time"); } @@ -24,8 +25,9 @@ pub fn schedule(db: &SqlitePool, config: &Config) { let mut sched = JobScheduler::new(); // Every hour + let db_for_hourly = db.clone(); sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || { - let db_clone = db.clone(); + let db_clone = db_for_hourly.clone(); // Use block_in_place to run async code in the synchronous function; TODO: Make it // nicer one's rust (stable) support async closures task::block_in_place(|| { @@ -40,6 +42,19 @@ pub fn schedule(db: &SqlitePool, config: &Config) { }); })); + // January 1st at midnight - yearly role cleanup + let db_for_yearly = db.clone(); + sched.add(Job::new("0 0 0 1 1 * *".parse().unwrap(), move || { + let db_clone = db_for_yearly.clone(); + task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + if let Err(e) = yearly_role_cleanup::cleanup_roles(&db_clone).await { + log::error!("Yearly role cleanup error: {e}"); + } + }); + }); + })); + let mut interval = time::interval(Duration::from_secs(60)); loop { sched.tick(); diff --git a/src/scheduled/yearly_role_cleanup.rs b/src/scheduled/yearly_role_cleanup.rs new file mode 100644 index 0000000..cf237bd --- /dev/null +++ b/src/scheduled/yearly_role_cleanup.rs @@ -0,0 +1,158 @@ +use crate::model::{notification::Notification, role::Role}; +use sqlx::SqlitePool; + +pub async fn cleanup_roles(db: &SqlitePool) -> Result<(), String> { + log::info!("Starting yearly role cleanup..."); + + let mut tx = db.begin().await.map_err(|e| e.to_string())?; + + // Find all roles to remove + let paid_role = Role::find_by_name_tx(&mut tx, "paid") + .await + .ok_or("Role 'paid' not found")?; + let schueler_role = Role::find_by_name_tx(&mut tx, "Schüler") + .await + .ok_or("Role 'Schüler' not found")?; + let student_role = Role::find_by_name_tx(&mut tx, "Student") + .await + .ok_or("Role 'Student' not found")?; + let no_einschreibgebuehr_role = Role::find_by_name_tx(&mut tx, "no-einschreibgebuehr") + .await + .ok_or("Role 'no-einschreibgebuehr' not found")?; + let half_rennrudern_role = Role::find_by_name_tx(&mut tx, "half-rennrudern") + .await + .ok_or("Role 'half-rennrudern' not found")?; + let participated_schnupperkurs_role = + Role::find_by_name_tx(&mut tx, "participated_schnupperkurs") + .await + .ok_or("Role 'participated_schnupperkurs' not found")?; + + // Find scheckbuch role (needed to exclude users from "paid" removal -> they have still paid + // for the scheckbuch) + let scheckbuch_role = Role::find_by_name_tx(&mut tx, "scheckbuch") + .await + .ok_or("Role 'scheckbuch' not found")?; + + // Remove "paid" role from all users EXCEPT those with scheckbuch role + let paid_removed = sqlx::query!( + "DELETE FROM user_role + WHERE role_id = ? + AND user_id NOT IN ( + SELECT user_id FROM user_role WHERE role_id = ? + )", + paid_role.id, + scheckbuch_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + // Remove other roles from all users + let schueler_removed = + sqlx::query!("DELETE FROM user_role WHERE role_id = ?", schueler_role.id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let student_removed = sqlx::query!("DELETE FROM user_role WHERE role_id = ?", student_role.id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let no_einschreibgebuehr_removed = sqlx::query!( + "DELETE FROM user_role WHERE role_id = ?", + no_einschreibgebuehr_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let half_rennrudern_removed = sqlx::query!( + "DELETE FROM user_role WHERE role_id = ?", + half_rennrudern_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let participated_schnupperkurs_removed = sqlx::query!( + "DELETE FROM user_role WHERE role_id = ?", + participated_schnupperkurs_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + // Send notifications to admins and Vorstand + let admin_role = Role::find_by_name_tx(&mut tx, "admin") + .await + .ok_or("Role 'admin' not found")?; + let vorstand_role = Role::find_by_name_tx(&mut tx, "Vorstand") + .await + .ok_or("Role 'Vorstand' not found")?; + + let notification_message_admin = format!( + "Jährliche Rollenbereinigung abgeschlossen. Die folgenden Rollen wurden entfernt: \ + paid ({} Benutzer, außer Scheckbuch-Mitglieder), \ + Schüler/Student ({}/{} Benutzer), \ + no-einschreibgebuehr ({} Benutzer), \ + half-rennrudern ({} Benutzer), \ + participated_schnupperkurs ({} Benutzer). \ + Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.", + paid_removed, + schueler_removed, + student_removed, + no_einschreibgebuehr_removed, + half_rennrudern_removed, + participated_schnupperkurs_removed + ); + let notification_message_vorstand = format!( + "Jährliche Rollenbereinigung abgeschlossen. \ + Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.", + ); + + // Notify admins + Notification::create_for_role_tx( + &mut tx, + &admin_role, + ¬ification_message_admin, + "Systembenachrichtigung", + Some("https://app.rudernlinz.at/admin/user/fees"), + None, + ) + .await; + + // Notify Vorstand + Notification::create_for_role_tx( + &mut tx, + &vorstand_role, + ¬ification_message_vorstand, + "Systembenachrichtigung", + Some("https://app.rudernlinz.at/admin/user/fees"), + None, + ) + .await; + + // Commit transaction + tx.commit().await.map_err(|e| e.to_string())?; + + log::info!( + "Yearly role cleanup completed successfully: \ + paid={}, Schüler={}, Student={}, no-einschreibgebuehr={}, \ + half-rennrudern={}, participated_schnupperkurs={} removals", + paid_removed, + schueler_removed, + student_removed, + no_einschreibgebuehr_removed, + half_rennrudern_removed, + participated_schnupperkurs_removed + ); + + Ok(()) +}