notification #292
| @@ -150,3 +150,12 @@ CREATE TABLE IF NOT EXISTS "boathouse" ( | ||||
|     CONSTRAINT unq UNIQUE (aisle, side, level) -- only 1 boat allowed to rest at each space  | ||||
| ); | ||||
|  | ||||
| CREATE TABLE IF NOT EXISTS "notification" ( | ||||
| 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||
| 	"user_id" INTEGER NOT NULL REFERENCES user(id), | ||||
| 	"message" TEXT NOT NULL, | ||||
| 	"read_at" DATETIME, | ||||
| 	"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, | ||||
| 	"category" TEXT NOT NULL, | ||||
| 	"link" TEXT | ||||
| ); | ||||
|   | ||||
| @@ -61,3 +61,4 @@ INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_ste | ||||
| INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3); | ||||
| INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02'); | ||||
| INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1); | ||||
| INSERT INTO "notification" (user_id, message, category) VALUES (1, 'This is a test notification', 'test-cat'); | ||||
|   | ||||
| @@ -5,7 +5,7 @@ use rocket::FromForm; | ||||
| use serde::Serialize; | ||||
| use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||
|  | ||||
| use super::{boat::Boat, log::Log, rower::Rower, user::User}; | ||||
| use super::{boat::Boat, log::Log, notification::Notification, rower::Rower, user::User}; | ||||
|  | ||||
| #[derive(FromRow, Serialize, Clone, Debug)] | ||||
| pub struct Logbook { | ||||
| @@ -525,6 +525,23 @@ ORDER BY departure DESC | ||||
|             Rower::create(db, self.id, *rower) | ||||
|                 .await | ||||
|                 .map_err(|e| LogbookUpdateError::RowerCreateError(*rower, e.to_string()))?; | ||||
|  | ||||
|             let user = User::find_by_id_tx(db, *rower as i32).await.unwrap(); | ||||
|             Notification::create_with_tx( | ||||
|                 db, | ||||
|                 &user, | ||||
|                 &format!( | ||||
|                     "Neuer Logbucheintrag: Ausfahrt am {}.{}.{} nach {} ({} km)", | ||||
|                     dep.day(), | ||||
|                     dep.month(), | ||||
|                     dep.year(), | ||||
|                     log.destination, | ||||
|                     log.distance_in_km | ||||
|                 ), | ||||
|                 "Neuer Logbucheintrag", | ||||
|                 None, | ||||
|             ) | ||||
|             .await; | ||||
|         } | ||||
|  | ||||
|         sqlx::query!( | ||||
|   | ||||
| @@ -16,6 +16,7 @@ pub mod log; | ||||
| pub mod logbook; | ||||
| pub mod logtype; | ||||
| pub mod mail; | ||||
| pub mod notification; | ||||
| pub mod planned_event; | ||||
| pub mod role; | ||||
| pub mod rower; | ||||
|   | ||||
| @@ -1,36 +1,78 @@ | ||||
| use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; | ||||
| use std::ops::DerefMut; | ||||
|  | ||||
| use chrono::NaiveDateTime; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use sqlx::{FromRow, SqlitePool}; | ||||
| use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||
|  | ||||
| use super::user::User; | ||||
|  | ||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||
| pub struct Notification { | ||||
|     pub id: i64, | ||||
|     pub user_id: i64, | ||||
|     pub message: String, | ||||
|     pub read_at: NaiveDateTime, | ||||
|     pub read_at: Option<NaiveDateTime>, | ||||
|     pub created_at: NaiveDateTime, | ||||
|     pub category: String, | ||||
|     pub link: Option<String>, | ||||
| } | ||||
|  | ||||
| impl Notification { | ||||
|     //pub async fn create(db: &SqlitePool, msg: String) -> bool { | ||||
|     //    sqlx::query!("INSERT INTO log(msg) VALUES (?)", msg,) | ||||
|     //        .execute(db) | ||||
|     //        .await | ||||
|     //        .is_ok() | ||||
|     //} | ||||
|     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||
|         sqlx::query_as!(Self, "SELECT  * FROM notification WHERE id like ?", id) | ||||
|             .fetch_one(db) | ||||
|             .await | ||||
|             .ok() | ||||
|     } | ||||
|     pub async fn create_with_tx( | ||||
|         db: &mut Transaction<'_, Sqlite>, | ||||
|         user: &User, | ||||
|         message: &str, | ||||
|         category: &str, | ||||
|         link: Option<&str>, | ||||
|     ) { | ||||
|         sqlx::query!( | ||||
|             "INSERT INTO notification(user_id, message, category, link) VALUES (?, ?, ?, ?)", | ||||
|             user.id, | ||||
|             message, | ||||
|             category, | ||||
|             link | ||||
|         ) | ||||
|         .execute(db.deref_mut()) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     } | ||||
|  | ||||
|     async fn for_user(db: &SqlitePool, user: &User) -> Vec<Self> { | ||||
|     pub async fn create( | ||||
|         db: &SqlitePool, | ||||
|         user: &User, | ||||
|         message: &str, | ||||
|         category: &str, | ||||
|         link: Option<&str>, | ||||
|     ) { | ||||
|         let mut tx = db.begin().await.unwrap(); | ||||
|         Self::create_with_tx(&mut tx, user, message, category, link).await; | ||||
|         tx.commit().await.unwrap(); | ||||
|     } | ||||
|  | ||||
|     pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<Self> { | ||||
|         sqlx::query_as!( | ||||
|             Log, | ||||
|             " | ||||
| SELECT id, user_id, message, read_at, category | ||||
| FROM notification | ||||
| WHERE user_id = {} | ||||
|     ", | ||||
|             Self, | ||||
|             "SELECT * FROM notification WHERE user_id = ?", | ||||
|             user.id | ||||
|         ) | ||||
|         .fetch_all(db) | ||||
|         .await | ||||
|         .unwrap() | ||||
|     } | ||||
|  | ||||
|     pub async fn mark_read(self, db: &SqlitePool) { | ||||
|         sqlx::query!( | ||||
|             "UPDATE notification SET read_at=CURRENT_TIMESTAMP WHERE id=?", | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(db) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -3,21 +3,22 @@ use serde::Serialize; | ||||
| use sqlx::SqlitePool; | ||||
|  | ||||
| use super::{ | ||||
|     notification::Notification, | ||||
|     planned_event::{PlannedEvent, Registration}, | ||||
|     tripdetails::TripDetails, | ||||
|     triptype::TripType, | ||||
|     user::CoxUser, | ||||
|     user::{CoxUser, User}, | ||||
| }; | ||||
|  | ||||
| #[derive(Serialize, Clone, Debug)] | ||||
| pub struct Trip { | ||||
|     id: i64, | ||||
|     cox_id: i64, | ||||
|     pub cox_id: i64, | ||||
|     cox_name: String, | ||||
|     trip_details_id: Option<i64>, | ||||
|     planned_starting_time: String, | ||||
|     pub max_people: i64, | ||||
|     day: String, | ||||
|     pub day: String, | ||||
|     pub notes: Option<String>, | ||||
|     pub allow_guests: bool, | ||||
|     trip_type_id: Option<i64>, | ||||
| @@ -43,6 +44,51 @@ impl Trip { | ||||
|         ) | ||||
|         .execute(db) | ||||
|         .await; | ||||
|  | ||||
|         let same_starting_datetime = TripDetails::find_by_startingdatetime( | ||||
|             db, | ||||
|             trip_details.day, | ||||
|             trip_details.planned_starting_time, | ||||
|         ) | ||||
|         .await; | ||||
|         if same_starting_datetime.len() > 1 { | ||||
|             for notify in same_starting_datetime { | ||||
|                 if notify.id != trip_details.id { | ||||
|                     // notify everyone except oneself | ||||
|                     if let Some(trip) = Trip::find_by_trip_details(db, notify.id).await { | ||||
|                         let user = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||
|                         Notification::create( | ||||
|                             db, | ||||
|                             &user, | ||||
|                             &format!( | ||||
|                                 "{} hat eine Ausfahrt zur selben Zeit ({} um {}) wie du erstellt", | ||||
|                                 user.name, trip.day, trip.planned_starting_time | ||||
|                             ), | ||||
|                             "Neue Ausfahrt zur selben Zeit".into(), | ||||
|                             None, | ||||
|                         ) | ||||
|                         .await; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn find_by_trip_details(db: &SqlitePool, tripdetails_id: i64) -> Option<Self> { | ||||
|         sqlx::query_as!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, trip_details.notes, allow_guests, trip_type_id, always_show, is_locked | ||||
| FROM trip  | ||||
| INNER JOIN trip_details ON trip.trip_details_id = trip_details.id | ||||
| INNER JOIN user ON trip.cox_id = user.id | ||||
| WHERE trip_details.id=? | ||||
|         ", | ||||
|             tripdetails_id | ||||
|         ) | ||||
|         .fetch_one(db) | ||||
|         .await | ||||
|         .ok() | ||||
|     } | ||||
|  | ||||
|     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||
|   | ||||
| @@ -46,6 +46,24 @@ WHERE id like ? | ||||
|         .ok() | ||||
|     } | ||||
|  | ||||
|     pub async fn find_by_startingdatetime( | ||||
|         db: &SqlitePool, | ||||
|         day: String, | ||||
|         planned_starting_time: String, | ||||
|     ) -> Vec<Self> { | ||||
|         sqlx::query_as!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked | ||||
| FROM trip_details  | ||||
| WHERE day = ? AND planned_starting_time = ? | ||||
|         " | ||||
|         , day, planned_starting_time | ||||
|         ) | ||||
|         .fetch_all(db) | ||||
|         .await.unwrap() | ||||
|     } | ||||
|  | ||||
|     /// Creates a new entry in `trip_details` and returns its id. | ||||
|     pub async fn create(db: &SqlitePool, tripdetails: TripDetailsToAdd<'_>) -> i64 { | ||||
|         let query = sqlx::query!( | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use sqlx::SqlitePool; | ||||
|  | ||||
| use super::{tripdetails::TripDetails, user::User}; | ||||
| use super::{notification::Notification, trip::Trip, tripdetails::TripDetails, user::User}; | ||||
| use crate::model::tripdetails::{Action, CoxAtTrip::Yes}; | ||||
|  | ||||
| pub struct UserTrip {} | ||||
| @@ -27,6 +27,7 @@ impl UserTrip { | ||||
|         //TODO: Check if user sees the event (otherwise she could forge trip_details_id) | ||||
|  | ||||
|         let is_cox = trip_details.user_is_cox(db, user).await; | ||||
|         let mut name_newly_registered_person = String::new(); | ||||
|         if user_note.is_none() { | ||||
|             if let Yes(action) = is_cox { | ||||
|                 match action { | ||||
| @@ -47,6 +48,8 @@ impl UserTrip { | ||||
|             .execute(db) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|             name_newly_registered_person = user.name.clone(); | ||||
|         } else { | ||||
|             if !trip_details.user_allowed_to_change(db, user).await { | ||||
|                 return Err(UserTripError::NotAllowedToAddGuest); | ||||
| @@ -59,6 +62,23 @@ impl UserTrip { | ||||
|             .execute(db) | ||||
|             .await | ||||
|             .unwrap(); | ||||
|  | ||||
|             name_newly_registered_person = user_note.unwrap(); | ||||
|         } | ||||
|  | ||||
|         if let Some(trip) = Trip::find_by_trip_details(db, trip_details.id).await { | ||||
|             let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||
|             Notification::create( | ||||
|                 db, | ||||
|                 &cox, | ||||
|                 &format!( | ||||
|                     "{} hat sich für deine Ausfahrt am {} registriert", | ||||
|                     name_newly_registered_person, trip.day | ||||
|                 ), | ||||
|                 "Registrierung bei Ausfahrt", | ||||
|                 None, | ||||
|             ) | ||||
|             .await; | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|   | ||||
| @@ -17,7 +17,10 @@ use serde::Deserialize; | ||||
| use sqlx::SqlitePool; | ||||
| use tera::Context; | ||||
|  | ||||
| use crate::model::user::{User, UserWithRoles}; | ||||
| use crate::model::{ | ||||
|     notification::Notification, | ||||
|     user::{User, UserWithRoles}, | ||||
| }; | ||||
|  | ||||
| pub(crate) mod admin; | ||||
| mod auth; | ||||
| @@ -27,6 +30,7 @@ mod cox; | ||||
| mod ergo; | ||||
| mod log; | ||||
| mod misc; | ||||
| mod notification; | ||||
| mod planned; | ||||
| mod stat; | ||||
|  | ||||
| @@ -43,6 +47,7 @@ async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_ | ||||
|         context.insert("flash", &msg.into_inner()); | ||||
|     } | ||||
|  | ||||
|     context.insert("notifications", &Notification::for_user(db, &user).await); | ||||
|     context.insert("loggedin_user", &UserWithRoles::from_user(user, db).await); | ||||
|     Template::render("index", context.into_json()) | ||||
| } | ||||
| @@ -86,6 +91,7 @@ pub fn config(rocket: Rocket<Build>) -> Rocket<Build> { | ||||
|         .mount("/log", log::routes()) | ||||
|         .mount("/planned", planned::routes()) | ||||
|         .mount("/ergo", ergo::routes()) | ||||
|         .mount("/notification", notification::routes()) | ||||
|         .mount("/stat", stat::routes()) | ||||
|         .mount("/boatdamage", boatdamage::routes()) | ||||
|         .mount("/cox", cox::routes()) | ||||
|   | ||||
							
								
								
									
										32
									
								
								src/tera/notification.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/tera/notification.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| use rocket::{ | ||||
|     get, | ||||
|     response::{Flash, Redirect}, | ||||
|     routes, Route, State, | ||||
| }; | ||||
| use sqlx::SqlitePool; | ||||
|  | ||||
| use crate::model::{notification::Notification, user::User}; | ||||
|  | ||||
| #[get("/<notification_id>/read")] | ||||
| async fn mark_read(db: &State<SqlitePool>, user: User, notification_id: i64) -> Flash<Redirect> { | ||||
|     let Some(notification) = Notification::find_by_id(db, notification_id).await else { | ||||
|         return Flash::error( | ||||
|             Redirect::to("/"), | ||||
|             format!("Nachricht mit ID {notification_id} nicht gefunden."), | ||||
|         ); | ||||
|     }; | ||||
|  | ||||
|     if notification.user_id == user.id { | ||||
|         notification.mark_read(db).await; | ||||
|         Flash::success(Redirect::to("/"), "Nachricht als gelesen markiert") | ||||
|     } else { | ||||
|         Flash::success( | ||||
|             Redirect::to("/"), | ||||
|             "Du kannst fremde Nachrichten nicht als gelesen markieren.", | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub fn routes() -> Vec<Route> { | ||||
|     routes![mark_read] | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| CREATE TABLE IF NOT EXISTS "notification" ( | ||||
| 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||
| 	"user_id" INTEGER NOT NULL REFERENCES user(id), | ||||
| 	"message" TEXT NOT NULL, | ||||
| 	"read_at" DATETIME, | ||||
| 	"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, | ||||
| 	"category" TEXT NOT NULL, | ||||
| 	"link" TEXT | ||||
| ); | ||||
|   | ||||
| @@ -3,6 +3,57 @@ | ||||
| {% block content %} | ||||
|     <div class="max-w-screen-lg w-full"> | ||||
|         <h1 class="h1">Ruderassistent</h1> | ||||
|         <div class="grid gap-3"> | ||||
|             <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" | ||||
|                  role="alert"> | ||||
|                 <h2 class="h2">Nachrichten</h2> | ||||
|                 <div class="text-sm p-3"> | ||||
|                     {% for notification in notifications %} | ||||
|                         {% if not notification.read_at %} | ||||
|                             <div class="relative flex bg-clip-border rounded-xl bg-white text-gray-700 shadow-md w-full flex-row"> | ||||
|                                 <div class="p-6"> | ||||
|                                     <h6 class="block mb-4 font-sans text-base antialiased font-semibold leading-relaxed tracking-normal text-gray-700 uppercase"> | ||||
|                                         {{ notification.category }} | ||||
|                                     </h6> | ||||
|                                     <h4 class="block mb-2 font-sans text-2xl antialiased font-semibold leading-snug tracking-normal text-blue-gray-900"> | ||||
|                                         {{ notification.message }} | ||||
|                                     </h4> | ||||
|                                     <p class="block mb-8 font-sans text-base antialiased font-normal leading-relaxed text-gray-700"> | ||||
|                                         {{ notification.created_at | date(format="%d.%m.%Y %H:%M") }} | ||||
|                                     </p> | ||||
|                                     {% if not notification.read_at %} | ||||
|                                         <a href="/notification/{{ notification.id }}/read" class="inline-block"> | ||||
|                                             <button class="flex items-center gap-2 px-6 py-3 font-sans text-xs font-bold text-center text-gray-900 uppercase align-middle transition-all rounded-lg select-none disabled:opacity-50 disabled:shadow-none disabled:pointer-events-none hover:bg-gray-900/10 active:bg-gray-900/20" | ||||
|                                                     type="button">Als gelesen markieren</button> | ||||
|                                         </a> | ||||
|                                     {% endif %} | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         {% endif %} | ||||
|                     {% endfor %} | ||||
|                     <details> | ||||
|                         <summary>Vergangene Nachrichten</summary> | ||||
|                         {% for notification in notifications %} | ||||
|                             {% if notification.read_at %} | ||||
|                                 <div class="relative flex bg-clip-border rounded-xl bg-white text-gray-700 shadow-md w-full flex-row"> | ||||
|                                     <div class="p-6"> | ||||
|                                         <h6 class="block mb-4 font-sans text-base antialiased font-semibold leading-relaxed tracking-normal text-gray-700 uppercase"> | ||||
|                                             {{ notification.category }} | ||||
|                                         </h6> | ||||
|                                         <h4 class="block mb-2 font-sans text-2xl antialiased font-semibold leading-snug tracking-normal text-blue-gray-900"> | ||||
|                                             {{ notification.message }} | ||||
|                                         </h4> | ||||
|                                         <p class="block mb-8 font-sans text-base antialiased font-normal leading-relaxed text-gray-700"> | ||||
|                                             {{ notification.created_at | date(format="%d.%m.%Y %H:%M") }} | ||||
|                                         </p> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             {% endif %} | ||||
|                         {% endfor %} | ||||
|                     </details> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="grid gap-3"> | ||||
|             <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" | ||||
|                  role="alert"> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user