use std::ops::DerefMut; use chrono::NaiveDateTime; use regex::Regex; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use super::{role::Role, user::User}; #[derive(FromRow, Debug, Serialize, Deserialize, Clone)] pub struct Notification { pub id: i64, pub user_id: i64, pub message: String, pub read_at: Option, pub created_at: NaiveDateTime, pub category: String, pub link: Option, pub action_after_reading: Option, } impl Notification { pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option { sqlx::query_as!(Self, "SELECT id, user_id, message, read_at, created_at, category, link, action_after_reading 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>, action_after_reading: Option<&str>, ) { sqlx::query!( "INSERT INTO notification(user_id, message, category, link, action_after_reading) VALUES (?, ?, ?, ?, ?)", user.id, message, category, link, action_after_reading ) .execute(db.deref_mut()) .await .unwrap(); } pub async fn create( db: &SqlitePool, user: &User, message: &str, category: &str, link: Option<&str>, action_after_reading: Option<&str>, ) { let mut tx = db.begin().await.unwrap(); Self::create_with_tx(&mut tx, user, message, category, link, action_after_reading).await; tx.commit().await.unwrap(); } pub async fn create_for_role_tx( db: &mut Transaction<'_, Sqlite>, role: &Role, message: &str, category: &str, link: Option<&str>, action_after_reading: Option<&str>, ) { let users = User::all_with_role_tx(db, role).await; for user in users { Self::create_with_tx(db, &user, message, category, link, action_after_reading).await; } } pub async fn create_for_role( db: &SqlitePool, role: &Role, message: &str, category: &str, link: Option<&str>, action_after_reading: Option<&str>, ) { let mut tx = db.begin().await.unwrap(); Self::create_for_role_tx(&mut tx, role, message, category, link, action_after_reading) .await; tx.commit().await.unwrap(); } pub async fn for_user(db: &SqlitePool, user: &User) -> Vec { let rows = sqlx::query!( " SELECT id, user_id, message, read_at, datetime(created_at, 'localtime') as created_at, category, link, action_after_reading FROM notification WHERE user_id = ? AND ( read_at IS NULL OR read_at >= datetime('now', '-14 days') ) AND created_at is not NULL ORDER BY read_at DESC, created_at DESC; ", user.id ) .fetch_all(db) .await .unwrap(); rows.into_iter() .map(|rec| Notification { id: rec.id, user_id: rec.user_id, message: rec.message, read_at: rec.read_at, created_at: NaiveDateTime::parse_from_str( &rec.created_at.unwrap(), "%Y-%m-%d %H:%M:%S", ) .unwrap(), category: rec.category, link: rec.link, action_after_reading: rec.action_after_reading, }) .collect() } 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(); if let Some(action) = self.action_after_reading.as_ref() { // User read notification about cancelled trip/event let re = Regex::new(r"^remove_user_trip_with_trip_details_id:(\d+)$").unwrap(); if let Some(caps) = re.captures(action) { if let Some(matched) = caps.get(1) { if let Ok(number) = matched.as_str().parse::() { let _ = sqlx::query!( "DELETE FROM user_trip WHERE user_id = ? AND trip_details_id = ?", self.user_id, number ) .execute(db) .await .unwrap(); } } } // Cox read notification about cancelled event let re = Regex::new(r"^remove_trip_by_event:(\d+)$").unwrap(); if let Some(caps) = re.captures(action) { if let Some(matched) = caps.get(1) { if let Ok(number) = matched.as_str().parse::() { let _ = sqlx::query!( "DELETE FROM trip WHERE cox_id = ? AND planned_event_id = ?", self.user_id, number ) .execute(db) .await .unwrap(); } } } } } pub(crate) async fn delete_by_action(db: &sqlx::Pool, action: &str) { sqlx::query!( "DELETE FROM notification WHERE action_after_reading=? and read_at is null", action ) .execute(db) .await .unwrap(); } } #[cfg(test)] mod test { use crate::{ model::{ event::{Event, EventUpdate, Registration}, notification::Notification, trip::Trip, tripdetails::{TripDetails, TripDetailsToAdd}, user::{CoxUser, User}, usertrip::UserTrip, }, testdb, }; use sqlx::SqlitePool; #[sqlx::test] fn event_canceled() { let pool = testdb!(); // Create event let add_tripdetails = TripDetailsToAdd { planned_starting_time: "10:00", max_people: 4, day: "1970-02-01".into(), notes: None, trip_type: None, allow_guests: false, always_show: false, }; let tripdetails_id = TripDetails::create(&pool, add_tripdetails).await; let trip_details = TripDetails::find_by_id(&pool, tripdetails_id) .await .unwrap(); Event::create(&pool, "new-event".into(), 2, &trip_details).await; let event = Event::find_by_trip_details(&pool, trip_details.id) .await .unwrap(); // Rower + Cox joins let rower = User::find_by_name(&pool, "rower").await.unwrap(); UserTrip::create(&pool, &rower, &trip_details, None) .await .unwrap(); let cox = CoxUser::new(&pool, User::find_by_name(&pool, "cox").await.unwrap()) .await .unwrap(); Trip::new_join(&pool, &cox, &event).await.unwrap(); // Cancel Event let cancel_update = EventUpdate { name: &event.name, planned_amount_cox: event.planned_amount_cox as i32, max_people: 0, notes: event.notes.as_deref(), always_show: event.always_show, is_locked: event.is_locked, }; event.update(&pool, &cancel_update).await; // Rower received notification let notifications = Notification::for_user(&pool, &rower).await; let rower_notification = notifications[0].clone(); assert_eq!(rower_notification.category, "Absage Ausfahrt"); assert_eq!( rower_notification.action_after_reading.as_deref(), Some("remove_user_trip_with_trip_details_id:3") ); // Cox received notification let notifications = Notification::for_user(&pool, &cox.user).await; let cox_notification = notifications[0].clone(); assert_eq!(cox_notification.category, "Absage Ausfahrt"); assert_eq!( cox_notification.action_after_reading.as_deref(), Some("remove_trip_by_event:2") ); // Notification removed if cancellation is cancelled let update = EventUpdate { name: &event.name, planned_amount_cox: event.planned_amount_cox as i32, max_people: 3, notes: event.notes.as_deref(), always_show: event.always_show, is_locked: event.is_locked, }; event.update(&pool, &update).await; assert!(Notification::for_user(&pool, &rower).await.is_empty()); assert!(Notification::for_user(&pool, &cox.user).await.is_empty()); // Cancel event again event.update(&pool, &cancel_update).await; // Rower is removed if notification is accepted assert!(event.is_rower_registered(&pool, &rower).await); rower_notification.mark_read(&pool).await; assert!(!event.is_rower_registered(&pool, &rower).await); // Cox is removed if notification is accepted let registration = Registration::all_cox(&pool, event.id).await; assert_eq!(registration.len(), 1); assert_eq!(registration[0].name, "cox"); cox_notification.mark_read(&pool).await; let registration = Registration::all_cox(&pool, event.id).await; assert!(registration.is_empty()); } }