134 lines
4.5 KiB
Rust
134 lines
4.5 KiB
Rust
//! 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<Utc>,
|
|
pub end_time: DateTime<Utc>,
|
|
pub bikes: Vec<String>,
|
|
pub description: String,
|
|
pub status: ReservationStatus,
|
|
}
|
|
|
|
impl ReservationCard {
|
|
pub fn new(reservation: &Reservation, bikes: Vec<String>) -> 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!(
|
|
"<b>Association :</b> {unit}\n\
|
|
<b>Demandeur :</b> {requester}\n\
|
|
<b>Telegram :</b> {telegram}\n\
|
|
<b>E-mail :</b> {email}\n\
|
|
<b>Période :</b> {start} → {end}\n\
|
|
<b>Cargobike(s) :</b> {bikes}\n\
|
|
<b>Raison :</b> {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<Utc>) -> 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<Utc> {
|
|
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 = "<b>Fake</b> & Co".to_owned();
|
|
let details = card.details();
|
|
|
|
assert!(details.contains("<b>Fake</b> & Co"));
|
|
// Our own headings stay markup
|
|
assert!(details.contains("<b>Association :</b>"));
|
|
}
|
|
}
|