//! One file per message the group receives. //! //! Adding one means adding a module here and a variant on //! [`super::Notification`] — the wording never leaks into the rest of the app, //! and changing a message is a change to one file with no logic in it. pub mod reservation_decided; pub mod reservation_requested; use chrono::{DateTime, Utc}; use crate::core::models::reservation::{Reservation, ReservationId, ReservationStatus}; /// The facts every message about a reservation shows. /// /// Built once, from the reservation as stored plus the names of the bikes it /// holds: a renderer reads nothing itself, so it can neither fail nor be slow. #[derive(Debug, Clone)] pub struct ReservationCard { pub id: ReservationId, pub unit: String, pub requester: String, pub telegram: String, pub email: String, pub start_time: DateTime, pub end_time: DateTime, pub bikes: Vec, pub description: String, pub status: ReservationStatus, } impl ReservationCard { pub fn new(reservation: &Reservation, bikes: Vec) -> Self { let requester = reservation .users .iter() .find(|user| user.id == reservation.requester); ReservationCard { id: reservation.id, unit: reservation.unit.label().to_owned(), requester: requester.map_or_else( || format!("#{}", reservation.requester), |user| format!("{} {}", user.firstname, user.name), ), telegram: reservation.telegram.clone(), email: requester.map(|user| user.email.clone()).unwrap_or_default(), start_time: reservation.start_time, end_time: reservation.end_time, bikes, description: reservation.description.clone(), status: reservation.status, } } /// The block shared by every message about this reservation, so a request /// and the decision it becomes carry exactly the same facts. pub fn details(&self) -> String { let bikes = if self.bikes.is_empty() { "—".to_owned() } else { self.bikes.join(", ") }; format!( "Association : {unit}\n\ Demandeur : {requester}\n\ Telegram : {telegram}\n\ E-mail : {email}\n\ Période : {start} → {end}\n\ Cargobike(s) : {bikes}\n\ Raison : {description}", unit = escape(&self.unit), requester = escape(&self.requester), telegram = escape(&self.telegram), email = escape(&self.email), start = format_moment(self.start_time), end = format_moment(self.end_time), bikes = escape(&bikes), description = escape(&self.description), ) } } /// The three characters Telegram's HTML mode reads as markup pub fn escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") } /// Swiss local time, which is the only one the group cares about pub fn format_moment(moment: DateTime) -> String { moment .with_timezone(&chrono::FixedOffset::east_opt(2 * 3600).expect("valid offset")) .format("%d.%m.%Y %H:%M") .to_string() } #[cfg(test)] pub mod tests { use super::*; fn moment(day: u32, hour: u32) -> DateTime { use chrono::TimeZone; Utc.with_ymd_and_hms(2026, 8, day, hour, 0, 0).unwrap() } /// The fixture every message test renders pub fn card(status: ReservationStatus) -> ReservationCard { ReservationCard { id: 12, unit: "PolyNite".to_owned(), requester: "Milan Hyenne".to_owned(), telegram: "@tibiscuit_18".to_owned(), email: "milan.hyenne@epfl.ch".to_owned(), start_time: moment(25, 10), end_time: moment(26, 16), bikes: vec!["1000".to_owned(), "2000".to_owned()], description: "Transport du matériel".to_owned(), status, } } #[test] fn markup_in_a_user_value_is_escaped() { let mut card = card(ReservationStatus::Requested); card.unit = "Fake & Co".to_owned(); let details = card.details(); assert!(details.contains("<b>Fake</b> & Co")); // Our own headings stay markup assert!(details.contains("Association :")); } }