cargagep-v2/src/services/telegram/messages/mod.rs
Antoine Pelletier 51df9e75ad wip
2026-08-25 10:38:05 +02:00

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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// 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("&lt;b&gt;Fake&lt;/b&gt; &amp; Co"));
// Our own headings stay markup
assert!(details.contains("<b>Association :</b>"));
}
}