diff --git a/migration.sql b/migration.sql index 421ca30..4504f92 100644 --- a/migration.sql +++ b/migration.sql @@ -151,3 +151,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 +); diff --git a/seeds.sql b/seeds.sql index 783f219..188ac10 100644 --- a/seeds.sql +++ b/seeds.sql @@ -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'); diff --git a/src/model/logbook.rs b/src/model/logbook.rs index d3c6e92..9dbbf64 100644 --- a/src/model/logbook.rs +++ b/src/model/logbook.rs @@ -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!( + "Ausfahrt am {}.{}.{} nach {} ({} km)", + dep.day(), + dep.month(), + dep.year(), + log.destination, + log.distance_in_km + ), + "Neuer Logbucheintrag", + None, + ) + .await; } sqlx::query!( diff --git a/src/model/mod.rs b/src/model/mod.rs index 6423379..b1e0416 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -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; diff --git a/src/model/notification.rs b/src/model/notification.rs index 5c6c26d..8cfc6a1 100644 --- a/src/model/notification.rs +++ b/src/model/notification.rs @@ -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, + pub created_at: NaiveDateTime, pub category: String, + pub link: Option, } 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 { + 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 { + 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 { 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(); + } } diff --git a/src/model/trip.rs b/src/model/trip.rs index 9141a35..b4c5437 100644 --- a/src/model/trip.rs +++ b/src/model/trip.rs @@ -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, planned_starting_time: String, pub max_people: i64, - day: String, + pub day: String, pub notes: Option, pub allow_guests: bool, trip_type_id: Option, @@ -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 { + 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 { diff --git a/src/model/tripdetails.rs b/src/model/tripdetails.rs index aeec004..485e66f 100644 --- a/src/model/tripdetails.rs +++ b/src/model/tripdetails.rs @@ -46,6 +46,24 @@ WHERE id like ? .ok() } + pub async fn find_by_startingdatetime( + db: &SqlitePool, + day: String, + planned_starting_time: String, + ) -> Vec { + 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!( diff --git a/src/model/usertrip.rs b/src/model/usertrip.rs index f727bf8..47603dc 100644 --- a/src/model/usertrip.rs +++ b/src/model/usertrip.rs @@ -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 deiner Ausfahrt", + None, + ) + .await; } Ok(()) diff --git a/src/tera/mod.rs b/src/tera/mod.rs index ae52b07..0f1faf0 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -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, user: User, flash: Option) -> Rocket { .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()) diff --git a/src/tera/notification.rs b/src/tera/notification.rs new file mode 100644 index 0000000..da59ccc --- /dev/null +++ b/src/tera/notification.rs @@ -0,0 +1,32 @@ +use rocket::{ + get, + response::{Flash, Redirect}, + routes, Route, State, +}; +use sqlx::SqlitePool; + +use crate::model::{notification::Notification, user::User}; + +#[get("//read")] +async fn mark_read(db: &State, user: User, notification_id: i64) -> Flash { + 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 { + routes![mark_read] +} diff --git a/staging-diff.sql b/staging-diff.sql index e69de29..dd07ecf 100644 --- a/staging-diff.sql +++ b/staging-diff.sql @@ -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 +); diff --git a/templates/includes/macros.html.tera b/templates/includes/macros.html.tera index 497b872..1c49080 100644 --- a/templates/includes/macros.html.tera +++ b/templates/includes/macros.html.tera @@ -16,196 +16,176 @@
- {% if "Donau Linz" in loggedin_user.roles %} - - {% else %} - - {% endif %} - Hü - {{ loggedin_user.name }} - -
-
- - {% include "includes/question-icon" %} - FAQs - - {% if "scheckbuch" in loggedin_user.roles and loggedin_user.weight and loggedin_user.sex and loggedin_user.dob %} - - {% include "includes/book" %} - Ergo - - - {% endif %} - {% if "scheckbuch" not in loggedin_user.roles %} - - {% include "includes/book" %} - Logbuch - - - {% endif %} - {% if "admin" in loggedin_user.roles or "Vorstand" in loggedin_user.roles %} - - - - - Userverwaltung - - {% endif %} - - - - - - - Ausloggen - -
+ + Hü + {{ loggedin_user.name }} + +
+
+ {% if "scheckbuch" in loggedin_user.roles and loggedin_user.weight and loggedin_user.sex and loggedin_user.dob %} + + {% include "includes/book" %} + Ergo + + + {% endif %} + {% if "scheckbuch" not in loggedin_user.roles %} + + {% include "includes/book" %} + Logbuch + + + {% endif %} + + + + + + + Ausloggen +
-
-
- {% endmacro header %} - {% macro input(label, name, type, required=false, class='rounded-md', value='', min='', hide_label=false, id='', autofocus=false, wrapper_class='', pattern='', readonly=false, accept='') %} -
- -
- {% endmacro input %} - {% macro checkbox(label, name, id='', checked=false, class='', disabled=false) %} -