diff --git a/db.sqlite-journal b/db.sqlite-journal new file mode 100644 index 0000000..eb04d76 Binary files /dev/null and b/db.sqlite-journal differ diff --git a/src/lib.rs b/src/lib.rs index 61615f9..b21dcb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ #![allow(clippy::blocks_in_conditions)] +use std::ops::Deref; + pub mod model; #[cfg(feature = "rowing-tera")] @@ -22,6 +24,74 @@ pub(crate) const FOERDERND: i64 = 8500; pub(crate) const SCHECKBUCH: i64 = 3000; pub(crate) const EINSCHREIBGEBUEHR: i64 = 3000; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(NonEmptyString(s)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +// Implement Deref to allow automatic dereferencing to &str +impl Deref for NonEmptyString { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// This allows &NonEmptyString to be converted to &str +impl AsRef for NonEmptyString { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// This allows NonEmptyString to be converted to String with .into() +impl From for String { + fn from(s: NonEmptyString) -> Self { + s.0 + } +} + +impl TryFrom<&str> for NonEmptyString { + type Error = &'static str; + + fn try_from(s: &str) -> Result { + if s.is_empty() { + Err("String cannot be empty") + } else { + Ok(NonEmptyString(s.to_string())) + } + } +} + +impl TryFrom for NonEmptyString { + type Error = &'static str; + + fn try_from(s: String) -> Result { + if s.is_empty() { + Err("String cannot be empty") + } else { + Ok(NonEmptyString(s)) + } + } +} + #[cfg(test)] #[macro_export] macro_rules! testdb { diff --git a/src/model/user/basic.rs b/src/model/user/basic.rs index 60a3cd7..82916b4 100644 --- a/src/model/user/basic.rs +++ b/src/model/user/basic.rs @@ -42,12 +42,20 @@ impl User { db: &SqlitePool, updated_by: &ManageUserUser, new_phone: &str, - ) -> Result<(), String> { + ) { let new_phone = new_phone.trim(); let query = if new_phone.is_empty() { + if self.phone.is_none() { + return; // nothing to do + } sqlx::query!("UPDATE user SET phone = NULL where id = ?", self.id) } else { + if let Some(old_phone) = &self.phone { + if old_phone == new_phone { + return; //nothing to do + } + } sqlx::query!("UPDATE user SET phone = ? where id = ?", new_phone, self.id) }; query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id @@ -58,8 +66,6 @@ impl User { None => format!("{updated_by} has added a phone number for {self}: {new_phone}") }; Log::create(db, msg).await; - - Ok(()) } pub(crate) async fn update_address( @@ -67,12 +73,20 @@ impl User { db: &SqlitePool, updated_by: &ManageUserUser, new_address: &str, - ) -> Result<(), String> { + ) { let new_address = new_address.trim(); let query = if new_address.is_empty() { + if !self.address.is_none() { + return; // nothing to do + } sqlx::query!("UPDATE user SET address = NULL where id = ?", self.id) } else { + if let Some(old_address) = &self.address { + if old_address == new_address { + return; //nothing to do + } + } sqlx::query!( "UPDATE user SET address = ? where id = ?", new_address, @@ -87,8 +101,6 @@ impl User { None => format!("{updated_by} has added an address for {self}: {new_address}") }; Log::create(db, msg).await; - - Ok(()) } pub(crate) async fn update_nickname( @@ -313,6 +325,9 @@ impl User { if self.has_membership_pdf(db).await { return Err(format!("User {self} hat bereits eine Beitrittserklärung.")); } + if membership_pdf.len() == 0 { + return Err(format!("Keine Beitrittserklärung mitgeschickt.")); + } let mut stream = membership_pdf.open().await.unwrap(); let mut buffer = Vec::new(); diff --git a/src/model/user/member.rs b/src/model/user/member.rs index faebd87..9161ac6 100644 --- a/src/model/user/member.rs +++ b/src/model/user/member.rs @@ -1,7 +1,6 @@ use super::ScheckbuchUser; use crate::model::{ logbook::{Logbook, LogbookWithBoatAndRowers}, - role::Role, user::User, }; use serde::{Deserialize, Serialize}; diff --git a/src/model/user/mod.rs b/src/model/user/mod.rs index ba89924..a99a633 100644 --- a/src/model/user/mod.rs +++ b/src/model/user/mod.rs @@ -1,15 +1,12 @@ -use std::{ - fmt::Display, - ops::{Deref, DerefMut}, -}; +use std::{fmt::Display, ops::DerefMut}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use chrono::{Datelike, Local, NaiveDate}; use log::info; +use rocket::async_trait; use rocket::{ - async_trait, http::{Cookie, Status}, - request::{self, FromRequest, Outcome}, + request::{FromRequest, Outcome}, time::{Duration, OffsetDateTime}, tokio::io::AsyncReadExt, Request, @@ -35,6 +32,7 @@ use scheckbuch::ScheckbuchUser; mod basic; mod fee; pub(crate) mod member; +pub(crate) mod regular; pub(crate) mod scheckbuch; #[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)] @@ -119,10 +117,7 @@ impl User { )); }; - if self.has_role(db, "Donau Linz").await { - self.send_welcome_mail_full_member(db, mail, smtp_pw) - .await?; - } else if self.has_role(db, "schnupperant").await { + if self.has_role(db, "schnupperant").await { self.send_welcome_mail_schnupper(db, mail, smtp_pw).await?; } else if let Some(scheckbuch) = ScheckbuchUser::new(db, self).await { scheckbuch.notify(db, mail, smtp_pw).await?; @@ -182,57 +177,6 @@ ASKÖ Ruderverein Donau Linz", self.name), Ok(()) } - async fn send_welcome_mail_full_member( - &self, - db: &SqlitePool, - mail: &str, - smtp_pw: &str, - ) -> Result<(), String> { - // 2 things to do: - // 1. Send mail to user - Mail::send_single( - db, - mail, - "Willkommen im ASKÖ Ruderverein Donau Linz!", - format!( -"Hallo {0}, - -herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen. - -Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung. - -Du kannst auch gerne unserer Signal-Gruppe beitreten, um auf dem Laufenden zu bleiben und dich mit anderen Mitgliedern auszutauschen: https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH - -Für die Organisation unserer Ausfahrten nutzen wir app.rudernlinz.at. Logge dich einfach mit deinem Namen ('{0}' ohne Anführungszeichen) ein, beim ersten Mal kannst du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst du dich jederzeit zu den Ausfahrten anmelden. - -Beim nächsten Treffen im Verein, erinnere jemand vom Vorstand (https://rudernlinz.at/unser-verein/vorstand/) bitte daran, deinen Fingerabdruck zu registrieren, damit du Zugang zum Bootshaus erhältst. - -Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter. - -Wir freuen uns darauf, dich bald am Wasser zu sehen und gemeinsam tolle Erfahrungen zu sammeln! - -Riemen- & Dollenbruch -ASKÖ Ruderverein Donau Linz", self.name), - smtp_pw, - ).await?; - - // 2. Notify all coxes - Notification::create_for_steering_people( - db, - &format!( - "Liebe Steuerberechtigte, seit {} gibt es ein neues Mitglied: {}", - self.member_since_date.clone().unwrap(), - self.name - ), - "Neues Vereinsmitglied", - None, - None, - ) - .await; - - Ok(()) - } - pub async fn amount_boats(&self, db: &SqlitePool) -> i64 { sqlx::query!( "SELECT COUNT(*) as count FROM boat WHERE owner = ?", @@ -904,7 +848,7 @@ ASKÖ Ruderverein Donau Linz", self.name), impl<'r> FromRequest<'r> for User { type Error = LoginError; - async fn from_request(req: &'r Request<'_>) -> request::Outcome { + async fn from_request(req: &'r Request<'_>) -> rocket::request::Outcome { match req.cookies().get_private("loggedin_user") { Some(user_id) => match user_id.value().parse::() { Ok(user_id) => { @@ -939,7 +883,7 @@ macro_rules! special_user { pub(crate) user: User, } - impl Deref for $name { + impl std::ops::Deref for $name { type Target = User; fn deref(&self) -> &Self::Target { &self.user @@ -953,20 +897,20 @@ macro_rules! special_user { } #[async_trait] - impl<'r> FromRequest<'r> for $name { - type Error = LoginError; - async fn from_request(req: &'r Request<'_>) -> request::Outcome { + impl<'r> rocket::request::FromRequest<'r> for $name { + type Error = crate::model::user::LoginError; + async fn from_request(req: &'r rocket::request::Request<'_>) -> rocket::request::Outcome { let db = req.rocket().state::().unwrap(); match User::from_request(req).await { - Outcome::Success(user) => { + rocket::request::Outcome::Success(user) => { if special_user!(@check_roles user, db, $($role)*) { - Outcome::Success($name { user }) + rocket::request::Outcome::Success($name { user }) } else { - Outcome::Forward(Status::Forbidden) + rocket::request::Outcome::Forward(rocket::http::Status::Forbidden) } } - Outcome::Error(f) => Outcome::Error(f), - Outcome::Forward(f) => Outcome::Forward(f), + rocket::request::Outcome::Error(f) => rocket::request::Outcome::Error(f), + rocket::request::Outcome::Forward(f) => rocket::request::Outcome::Forward(f), } } } diff --git a/src/model/user/regular.rs b/src/model/user/regular.rs new file mode 100644 index 0000000..de557b8 --- /dev/null +++ b/src/model/user/regular.rs @@ -0,0 +1,73 @@ +use super::User; +use crate::{ + model::{mail::Mail, notification::Notification}, + special_user, +}; +use rocket::async_trait; +use sqlx::SqlitePool; + +special_user!(RegularUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); + +impl RegularUser { + pub(crate) async fn notify(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> { + self.notify_coxes_about_new_regular(db).await; + self.send_welcome_mail_to_user(db, smtp_pw).await?; + + Ok(()) + } + + async fn send_welcome_mail_to_user( + &self, + db: &SqlitePool, + smtp_pw: &str, + ) -> Result<(), String> { + let Some(mail) = &self.mail else { + return Err(format!( + "Couldn't send welcome mail, as the user {self} has no mail..." + )); + }; + + Mail::send_single( + db, + mail, + "Willkommen im ASKÖ Ruderverein Donau Linz!", + format!( +"Hallo {0}, + +herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen. + +Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung. + +Du kannst auch gerne unserer Signal-Gruppe beitreten, um auf dem Laufenden zu bleiben und dich mit anderen Mitgliedern auszutauschen: https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH + +Für die Organisation unserer Ausfahrten nutzen wir app.rudernlinz.at. Logge dich einfach mit deinem Namen ('{0}' ohne Anführungszeichen) ein, beim ersten Mal kannst du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst du dich jederzeit zu den Ausfahrten anmelden. + +Beim nächsten Treffen im Verein, erinnere jemand vom Vorstand (https://rudernlinz.at/unser-verein/vorstand/) bitte daran, deinen Fingerabdruck zu registrieren, damit du Zugang zum Bootshaus erhältst. + +Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter. + +Wir freuen uns darauf, dich bald am Wasser zu sehen und gemeinsam tolle Erfahrungen zu sammeln! + +Riemen- & Dollenbruch +ASKÖ Ruderverein Donau Linz", self.name), + smtp_pw, + ).await?; + + Ok(()) + } + + async fn notify_coxes_about_new_regular(&self, db: &SqlitePool) { + Notification::create_for_steering_people( + db, + &format!( + "Liebe Steuerberechtigte, seit {} gibt es ein neues Mitglied: {}", + self.member_since_date.clone().unwrap(), + self.name + ), + "Neues Vereinsmitglied", + None, + None, + ) + .await; + } +} diff --git a/src/model/user/scheckbuch.rs b/src/model/user/scheckbuch.rs index 9a66521..9273eb5 100644 --- a/src/model/user/scheckbuch.rs +++ b/src/model/user/scheckbuch.rs @@ -1,8 +1,7 @@ -use super::member::Member; +use super::regular::RegularUser; use super::{ManageUserUser, User}; use crate::model::role::Role; -use crate::model::user::LoginError; -use crate::tera::admin::user::ScheckToRegularForm; +use crate::NonEmptyString; use crate::{ model::{mail::Mail, notification::Notification}, special_user, SCHECKBUCH, @@ -10,13 +9,7 @@ use crate::{ use chrono::NaiveDate; use rocket::async_trait; use rocket::fs::TempFile; -use rocket::http::Status; -use rocket::request; -use rocket::request::FromRequest; -use rocket::request::Outcome; -use rocket::Request; use sqlx::SqlitePool; -use std::ops::Deref; special_user!(ScheckbuchUser, +"scheckbuch"); @@ -24,11 +17,12 @@ impl ScheckbuchUser { pub(crate) async fn convert_to_regular_user( self, db: &SqlitePool, + smtp_pw: &str, changed_by: &ManageUserUser, member_since: &NaiveDate, birthdate: &NaiveDate, - phone: &str, - address: &str, + phone: NonEmptyString, + address: NonEmptyString, membership_pdf: &TempFile<'_>, ) -> Result<(), String> { // Set data @@ -36,9 +30,9 @@ impl ScheckbuchUser { self.user .update_member_since(db, changed_by, member_since) .await; - self.user.update_phone(db, changed_by, phone).await?; - self.user.update_address(db, changed_by, address).await?; - self.user.update_address(db, changed_by, address).await?; + + self.user.update_phone(db, changed_by, &phone).await; + self.user.update_address(db, changed_by, &address).await; self.user .add_membership_pdf(db, changed_by, membership_pdf) .await?; @@ -50,25 +44,13 @@ impl ScheckbuchUser { self.user.add_role(db, changed_by, ®ular).await?; // Notify - todo!() // Continue here + let regular = RegularUser::new(db, &self.user).await.unwrap(); + regular.notify(db, smtp_pw).await?; + + Ok(()) } - //async fn from(user: User, db: &SqlitePool, mail: &str, smtp_pw: &str) -> Result<(), String> { - // if user.has_role(db, "scheckbuch").await { - // return Err("User is already a scheckbuch".into()); - // } - - // // TODO: do we allow e.g. DonauLinz to scheckbuch? - - // let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); - // user.add_role(db, &scheckbuch).await.unwrap(); - - // // TODO: remove all other `membership_type` roles - // let new_user = Self::new(db, &user).await.unwrap(); - - // new_user.notify(db, mail, smtp_pw).await - //} - + // TODO: make private pub(crate) async fn notify( &self, db: &SqlitePool, diff --git a/src/tera/admin/user.rs b/src/tera/admin/user.rs index a9d2e4d..6b853ba 100644 --- a/src/tera/admin/user.rs +++ b/src/tera/admin/user.rs @@ -390,13 +390,11 @@ async fn update_phone( ); }; - match user.update_phone(db, &admin, &data.phone).await { - Ok(_) => Flash::success( - Redirect::to(format!("/admin/user/{}", user.id)), - "Telefonnummer erfolgreich geändert", - ), - Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), - } + user.update_phone(db, &admin, &data.phone).await; + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Telefonnummer erfolgreich geändert", + ) } #[derive(FromForm, Debug)] @@ -418,13 +416,12 @@ async fn update_address( ); }; - match user.update_address(db, &admin, &data.address).await { - Ok(_) => Flash::success( - Redirect::to(format!("/admin/user/{}", user.id)), - "Adresse erfolgreich geändert", - ), - Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), - } + user.update_address(db, &admin, &data.address).await; + + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Adresse erfolgreich geändert", + ) } #[derive(FromForm, Debug)] @@ -831,6 +828,7 @@ async fn scheckbook_to_regular( db: &State, data: Form>, admin: ManageUserUser, + config: &State, id: i32, ) -> Flash { let Some(user) = User::find_by_id(db, id).await else { @@ -842,13 +840,19 @@ async fn scheckbook_to_regular( let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else { return Flash::error( Redirect::to(format!("/admin/user/{id}")), - format!("Datum {} ist nicht im YYYY-MM-DD Format", &data.birthdate), + format!( + "Geburtsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), ); }; let Ok(member_since) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") else { return Flash::error( Redirect::to(format!("/admin/user/{id}")), - format!("Datum {} ist nicht im YYYY-MM-DD Format", &data.birthdate), + format!( + "Beitrittsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), ); }; @@ -859,14 +863,28 @@ async fn scheckbook_to_regular( ); }; + let Ok(phone) = data.phone.clone().try_into() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Vereinsmitglied braucht eine Telefonnummer", + ); + }; + let Ok(address) = data.address.clone().try_into() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Vereinsmitglied braucht eine Adresse", + ); + }; + match user .convert_to_regular_user( db, + &config.smtp_pw, &admin, &member_since, &birthdate, - &data.phone, - &data.address, + phone, + address, &data.membership_pdf, ) .await