diff --git a/migration.sql b/migration.sql index a2b0eb7..4c87b1a 100644 --- a/migration.sql +++ b/migration.sql @@ -48,10 +48,10 @@ CREATE TABLE IF NOT EXISTS "trip" ( ); CREATE TABLE IF NOT EXISTS "user_trip" ( - "user_id" INTEGER NOT NULL REFERENCES user(id), + "user_id" INTEGER REFERENCES user(id), + "user_note" text, -- only shown if user_id = none "trip_details_id" INTEGER NOT NULL REFERENCES trip_details(id), - "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT unq UNIQUE (user_id, trip_details_id) -- allow user to participate only once for each trip + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS "log" ( diff --git a/src/model/planned_event.rs b/src/model/planned_event.rs index 8444c2e..ae7dfa4 100644 --- a/src/model/planned_event.rs +++ b/src/model/planned_event.rs @@ -37,11 +37,12 @@ pub struct PlannedEventWithUserAndTriptype { } //TODO: move to appropriate place -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct Registration { pub name: String, pub registered_at: String, pub is_guest: bool, + pub is_real_guest: bool, } impl PlannedEvent { @@ -120,29 +121,40 @@ INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id", async fn get_all_cox(&self, db: &SqlitePool) -> Vec { //TODO: switch to join - sqlx::query_as!( - Registration, + sqlx::query!( " SELECT (SELECT name FROM user WHERE cox_id = id) as name, (SELECT created_at FROM user WHERE cox_id = id) as registered_at, - (SELECT is_guest FROM user WHERE cox_id = id) as is_guest + (SELECT is_guest FROM user WHERE cox_id = id) as is_guest, + 0 as is_real_guest FROM trip WHERE planned_event_id = ? ", self.id ) .fetch_all(db) .await - .unwrap() //Okay, as PlannedEvent can only be created with proper DB backing + .unwrap() + .into_iter() + .map(|r| Registration { + name: r.name, + registered_at: r.registered_at, + is_guest: r.is_guest, + is_real_guest: r.is_real_guest == 1, + }) + .collect() //Okay, as PlannedEvent can only be created with proper DB backing } async fn get_all_rower(&self, db: &SqlitePool) -> Vec { //TODO: switch to join - sqlx::query_as!( - Registration, + sqlx::query!( " SELECT - (SELECT name FROM user WHERE user_trip.user_id = user.id) as name, + CASE + WHEN user_id IS NOT NULL THEN (SELECT name FROM user WHERE user_trip.user_id = user.id) + ELSE user_note + END as name, + user_id IS NULL as is_real_guest, (SELECT created_at FROM user WHERE user_trip.user_id = user.id) as registered_at, (SELECT is_guest FROM user WHERE user_trip.user_id = user.id) as is_guest FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM planned_event WHERE id = ?) @@ -151,7 +163,15 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM planned_even ) .fetch_all(db) .await - .unwrap() //Okay, as PlannedEvent can only be created with proper DB backing + .unwrap() + .into_iter() + .map(|r| Registration { + name: r.name.unwrap(), + registered_at: r.registered_at, + is_guest: r.is_guest, + is_real_guest: r.is_real_guest == 1, + }) + .collect() } //TODO: add tests diff --git a/src/model/trip.rs b/src/model/trip.rs index 491127d..a7a4037 100644 --- a/src/model/trip.rs +++ b/src/model/trip.rs @@ -126,11 +126,14 @@ WHERE day=? } async fn get_all_rower(&self, db: &SqlitePool) -> Vec { - sqlx::query_as!( - Registration, + sqlx::query!( " SELECT - (SELECT name FROM user WHERE user_trip.user_id = user.id) as name, + CASE + WHEN user_id IS NOT NULL THEN (SELECT name FROM user WHERE user_trip.user_id = user.id) + ELSE user_note + END as name, + user_id IS NULL as is_real_guest, (SELECT created_at FROM user WHERE user_trip.user_id = user.id) as registered_at, (SELECT is_guest FROM user WHERE user_trip.user_id = user.id) as is_guest FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE id = ?)", @@ -138,7 +141,15 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE i ) .fetch_all(db) .await - .unwrap() //Okay, as Trip can only be created with proper DB backing + .unwrap() + .into_iter() + .map(|r| Registration { + name: r.name.unwrap(), + registered_at: r.registered_at, + is_guest: r.is_guest, + is_real_guest: r.is_real_guest == 1, + }) + .collect() } /// Cox decides to update own trip. @@ -497,7 +508,9 @@ mod test { .unwrap(); let user = User::find_by_name(&pool, "rower".into()).await.unwrap(); - UserTrip::create(&pool, &user, &trip_details).await.unwrap(); + UserTrip::create(&pool, &user, &trip_details, None) + .await + .unwrap(); let result = trip .delete(&pool, &cox) diff --git a/src/model/tripdetails.rs b/src/model/tripdetails.rs index 3b6b91c..b913810 100644 --- a/src/model/tripdetails.rs +++ b/src/model/tripdetails.rs @@ -1,3 +1,4 @@ +use crate::model::user::User; use chrono::NaiveDate; use rocket::FromForm; use serde::{Deserialize, Serialize}; @@ -89,6 +90,91 @@ ORDER BY day;", .map(|a| NaiveDate::parse_from_str(&a, "%Y-%m-%d").unwrap()) .collect() } + pub(crate) async fn user_is_rower(&self, db: &SqlitePool, user: &User) -> bool { + //check if cox if planned_event + let is_rower = sqlx::query!( + "SELECT count(*) as amount + FROM user_trip + WHERE trip_details_id = ? AND user_id = ?", + self.id, + user.id + ) + .fetch_one(db) + .await + .unwrap(); + is_rower.amount > 0 + } + + async fn belongs_to_event(&self, db: &SqlitePool) -> bool { + let amount = sqlx::query!( + "SELECT count(*) as amount + FROM planned_event + WHERE trip_details_id = ?", + self.id, + ) + .fetch_one(db) + .await + .unwrap(); + amount.amount > 0 + } + + pub(crate) async fn user_allowed_to_change(&self, db: &SqlitePool, user: &User) -> bool { + if self.belongs_to_event(db).await { + return user.is_admin; + } else { + return self.user_is_cox(db, user).await != CoxAtTrip::No; + } + } + + pub(crate) async fn user_is_cox(&self, db: &SqlitePool, user: &User) -> CoxAtTrip { + //check if cox if planned_event + let is_cox = sqlx::query!( + "SELECT count(*) as amount + FROM trip + WHERE planned_event_id = ( + SELECT id FROM planned_event WHERE trip_details_id = ? + ) + AND cox_id = ?", + self.id, + user.id + ) + .fetch_one(db) + .await + .unwrap(); + if is_cox.amount > 0 { + return CoxAtTrip::Yes(Action::Helping); + } + + //check if cox if own event + let is_cox = sqlx::query!( + "SELECT count(*) as amount + FROM trip + WHERE trip_details_id = ? + AND cox_id = ?", + self.id, + user.id + ) + .fetch_one(db) + .await + .unwrap(); + if is_cox.amount > 0 { + return CoxAtTrip::Yes(Action::Own); + } + + CoxAtTrip::No + } +} + +#[derive(PartialEq, Debug)] +pub(crate) enum CoxAtTrip { + No, + Yes(Action), +} + +#[derive(PartialEq, Debug)] +pub(crate) enum Action { + Helping, + Own, } #[cfg(test)] diff --git a/src/model/usertrip.rs b/src/model/usertrip.rs index 8b8500d..de133ed 100644 --- a/src/model/usertrip.rs +++ b/src/model/usertrip.rs @@ -1,6 +1,7 @@ use sqlx::SqlitePool; use super::{tripdetails::TripDetails, user::User}; +use crate::model::tripdetails::{Action, CoxAtTrip::Yes}; pub struct UserTrip {} @@ -9,6 +10,7 @@ impl UserTrip { db: &SqlitePool, user: &User, trip_details: &TripDetails, + user_note: Option, ) -> Result<(), UserTripError> { if trip_details.is_full(db).await { return Err(UserTripError::EventAlreadyFull); @@ -22,74 +24,84 @@ impl UserTrip { return Err(UserTripError::GuestNotAllowedForThisEvent); } - //TODO: Check if user sees the event (otherwise she could forge trip_details_id + //TODO: Check if user sees the event (otherwise she could forge trip_details_id) - //check if cox if own event - let is_cox = sqlx::query!( - "SELECT count(*) as amount - FROM trip - WHERE trip_details_id = ? - AND cox_id = ?", - trip_details.id, - user.id - ) - .fetch_one(db) - .await - .unwrap(); - if is_cox.amount > 0 { - return Err(UserTripError::CantRegisterAtOwnEvent); + let is_cox = trip_details.user_is_cox(db, user).await; + if user_note.is_none() { + if let Yes(action) = is_cox { + match action { + Action::Helping => return Err(UserTripError::AlreadyRegisteredAsCox), + Action::Own => return Err(UserTripError::CantRegisterAtOwnEvent), + }; + } + + if trip_details.user_is_rower(db, user).await { + return Err(UserTripError::AlreadyRegistered); + } + + sqlx::query!( + "INSERT INTO user_trip (user_id, trip_details_id) VALUES(?, ?)", + user.id, + trip_details.id, + ) + .execute(db) + .await + .unwrap(); + } else { + if !trip_details.user_allowed_to_change(db, user).await { + return Err(UserTripError::NotAllowedToAddGuest); + } + sqlx::query!( + "INSERT INTO user_trip (user_note, trip_details_id) VALUES(?, ?)", + user_note, + trip_details.id, + ) + .execute(db) + .await + .unwrap(); } - //TODO: can probably move to trip.rs? - //check if cox if planned_event - let is_cox = sqlx::query!( - "SELECT count(*) as amount - FROM trip - WHERE planned_event_id = ( - SELECT id FROM planned_event WHERE trip_details_id = ? - ) - AND cox_id = ?", - trip_details.id, - user.id - ) - .fetch_one(db) - .await - .unwrap(); - if is_cox.amount > 0 { - return Err(UserTripError::AlreadyRegisteredAsCox); - } - - match sqlx::query!( - "INSERT INTO user_trip (user_id, trip_details_id) VALUES(?, ?)", - user.id, - trip_details.id - ) - .execute(db) - .await - { - Ok(_) => Ok(()), - Err(_) => Err(UserTripError::AlreadyRegistered), - } + Ok(()) } pub async fn delete( db: &SqlitePool, user: &User, trip_details: &TripDetails, + name: Option, ) -> Result<(), UserTripDeleteError> { if trip_details.is_locked { return Err(UserTripDeleteError::DetailsLocked); } - let _ = sqlx::query!( - "DELETE FROM user_trip WHERE user_id = ? AND trip_details_id = ?", - user.id, - trip_details.id - ) - .execute(db) - .await - .unwrap(); + if let Some(name) = name { + if !trip_details.user_allowed_to_change(db, user).await { + return Err(UserTripDeleteError::NotAllowedToDeleteGuest); + } + if sqlx::query!( + "DELETE FROM user_trip WHERE user_note = ? AND trip_details_id = ?", + name, + trip_details.id + ) + .execute(db) + .await + .unwrap() + .rows_affected() + == 0 + { + return Err(UserTripDeleteError::GuestNotParticipating); + } + } else { + let _ = sqlx::query!( + "DELETE FROM user_trip WHERE user_id = ? AND trip_details_id = ?", + user.id, + trip_details.id + ) + .execute(db) + .await + .unwrap(); + } Ok(()) } } @@ -102,11 +114,14 @@ pub enum UserTripError { DetailsLocked, CantRegisterAtOwnEvent, GuestNotAllowedForThisEvent, + NotAllowedToAddGuest, } #[derive(Debug, PartialEq)] pub enum UserTripDeleteError { DetailsLocked, + GuestNotParticipating, + NotAllowedToDeleteGuest, } #[cfg(test)] @@ -130,7 +145,9 @@ mod test { let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); - UserTrip::create(&pool, &user, &trip_details).await.unwrap(); + UserTrip::create(&pool, &user, &trip_details, None) + .await + .unwrap(); } #[sqlx::test] @@ -143,12 +160,14 @@ mod test { let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); - UserTrip::create(&pool, &user, &trip_details).await.unwrap(); - UserTrip::create(&pool, &user2, &trip_details) + UserTrip::create(&pool, &user, &trip_details, None) + .await + .unwrap(); + UserTrip::create(&pool, &user2, &trip_details, None) .await .unwrap(); - let result = UserTrip::create(&pool, &user3, &trip_details) + let result = UserTrip::create(&pool, &user3, &trip_details, None) .await .expect_err("Expect registration to fail because trip is already full"); @@ -162,9 +181,11 @@ mod test { let user = User::find_by_name(&pool, "cox".into()).await.unwrap(); let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); - UserTrip::create(&pool, &user, &trip_details).await.unwrap(); + UserTrip::create(&pool, &user, &trip_details, None) + .await + .unwrap(); - let result = UserTrip::create(&pool, &user, &trip_details) + let result = UserTrip::create(&pool, &user, &trip_details, None) .await .expect_err("Expect registration to fail because user is same as responsible cox"); @@ -179,7 +200,7 @@ mod test { let trip_details = TripDetails::find_by_id(&pool, 2).await.unwrap(); - let result = UserTrip::create(&pool, &user, &trip_details) + let result = UserTrip::create(&pool, &user, &trip_details, None) .await .expect_err("Expect registration to fail because user is same as responsible cox"); @@ -196,12 +217,11 @@ mod test { .try_into() .unwrap(); - let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); let planned_event = PlannedEvent::find_by_id(&pool, 1).await.unwrap(); - Trip::new_join(&pool, &cox, &planned_event).await.unwrap(); - let result = UserTrip::create(&pool, &cox, &trip_details) + let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); + let result = UserTrip::create(&pool, &cox, &trip_details, None) .await .expect_err("Expect registration to fail because user is already registered as cox"); @@ -216,7 +236,7 @@ mod test { let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); - let result = UserTrip::create(&pool, &user, &trip_details) + let result = UserTrip::create(&pool, &user, &trip_details, None) .await .expect_err("Not allowed for guests"); diff --git a/src/tera/mod.rs b/src/tera/mod.rs index 04cb21e..145b404 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -46,13 +46,18 @@ async fn index(db: &State, user: User, flash: Option")] -async fn join(db: &State, trip_details_id: i64, user: User) -> Flash { +#[get("/join/?")] +async fn join( + db: &State, + trip_details_id: i64, + user: User, + user_note: Option, +) -> Flash { let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else { return Flash::error(Redirect::to("/"), "Trip_details do not exist."); }; - match UserTrip::create(db, &user, &trip_details).await { + match UserTrip::create(db, &user, &trip_details, user_note).await { Ok(_) => { Log::create( db, @@ -81,6 +86,10 @@ async fn join(db: &State, trip_details_id: i64, user: User) -> Flash Redirect::to("/"), "Bei dieser Ausfahrt können leider keine Gäste mitfahren.", ), + Err(UserTripError::NotAllowedToAddGuest) => Flash::error( + Redirect::to("/"), + "Du darfst keine Gäste hinzufügen.", + ), Err(UserTripError::DetailsLocked) => Flash::error( Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.", @@ -88,13 +97,18 @@ async fn join(db: &State, trip_details_id: i64, user: User) -> Flash } } -#[get("/remove/")] -async fn remove(db: &State, trip_details_id: i64, user: User) -> Flash { +#[get("/remove//")] +async fn remove_guest( + db: &State, + trip_details_id: i64, + user: User, + name: String, +) -> Flash { let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else { return Flash::error(Redirect::to("/"), "TripDetailsId does not exist"); }; - match UserTrip::delete(db, &user, &trip_details).await { + match UserTrip::delete(db, &user, &trip_details, Some(name)).await { Ok(_) => { Log::create( db, @@ -119,6 +133,50 @@ async fn remove(db: &State, trip_details_id: i64, user: User) -> Fla Flash::error(Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.") } + Err(UserTripDeleteError::GuestNotParticipating) => { + Flash::error(Redirect::to("/"), "Gast nicht angemeldet.") + } + Err(UserTripDeleteError::NotAllowedToDeleteGuest) => Flash::error( + Redirect::to("/"), + "Keine Berechtigung um den Gast zu entfernen.", + ), + } +} + +#[get("/remove/")] +async fn remove(db: &State, trip_details_id: i64, user: User) -> Flash { + let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else { + return Flash::error(Redirect::to("/"), "TripDetailsId does not exist"); + }; + + match UserTrip::delete(db, &user, &trip_details, None).await { + Ok(_) => { + Log::create( + db, + format!( + "User {} unregistered for trip_details.id={}", + user.name, trip_details_id + ), + ) + .await; + + Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!") + } + Err(UserTripDeleteError::DetailsLocked) => { + Log::create( + db, + format!( + "User {} tried to unregister for locked trip_details.id={}", + user.name, trip_details_id + ), + ) + .await; + + Flash::error(Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.") + } + Err(_) => { + panic!("Not possible to be here"); + } } } @@ -135,7 +193,7 @@ pub struct Config { pub fn config(rocket: Rocket) -> Rocket { rocket - .mount("/", routes![index, join, remove]) + .mount("/", routes![index, join, remove, remove_guest]) .mount("/auth", auth::routes()) .mount("/log", log::routes()) .mount("/stat", stat::routes()) diff --git a/templates/includes/macros.html.tera b/templates/includes/macros.html.tera index 3a2567a..de64ad4 100644 --- a/templates/includes/macros.html.tera +++ b/templates/includes/macros.html.tera @@ -103,7 +103,7 @@ {% endmacro alert %} -{% macro box(participants, empty_seats='', header='Freie Plätze:', text='Keine Ruderer angemeldet', bg='primary-600', color='white') %} +{% macro box(participants, empty_seats='', header='Freie Plätze:', text='Keine Ruderer angemeldet', bg='primary-600', color='white', trip_details_id='', allow_removing=false) %}
{{ header }} {{ empty_seats }}
@@ -111,7 +111,13 @@ {% for rower in participants %} {{ rower.name }} {% if rower.is_guest %} + (Scheckbuch) + {% endif %} + {% if rower.is_real_guest %} (Gast) + {% if allow_removing %} + Abmelden + {% endif %} {% endif %}
diff --git a/templates/index.html.tera b/templates/index.html.tera index 41aba07..5a5bd2c 100644 --- a/templates/index.html.tera +++ b/templates/index.html.tera @@ -115,10 +115,18 @@ {# --- START List Rowers --- #} {% if planned_event.max_people > 0 %} {% set amount_cur_rower = planned_event.rower | length %} - {{ macros::box(participants=planned_event.rower, empty_seats=planned_event.max_people - amount_cur_rower, bg='primary-100', color='black') }} + {{ macros::box(participants=planned_event.rower, empty_seats=planned_event.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=planned_event.trip_details_id, allow_removing=loggedin_user.is_admin) }} {% endif %} {# --- END List Rowers --- #} + {% if loggedin_user.is_admin %} +
+ {{ macros::input(label='Gast', name='user_note', type='text', required=true) }} + +
+ {% endif %} + + {% if planned_event.allow_guests %}
Gäste willkommen!
{% endif %} @@ -213,7 +221,13 @@ {{ macros::box(participants=trip.rower,bg='[#f43f5e]',header='Absage') }} {% else %} {% set amount_cur_rower = trip.rower | length %} - {{ macros::box(participants=trip.rower, empty_seats=trip.max_people - amount_cur_rower, bg='primary-100', color='black') }} + {{ macros::box(participants=trip.rower, empty_seats=trip.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=trip.trip_details_id, allow_removing=loggedin_user.id == trip.cox_id) }} + {% if trip.cox_id == loggedin_user.id %} +
+ {{ macros::input(label='Gast', name='user_note', type='text', required=true) }} + +
+ {% endif %} {% endif %} {# --- START Edit Form --- #}