165 lines
5 KiB
Rust
165 lines
5 KiB
Rust
//! One file per mail the app sends.
|
|
//!
|
|
//! Adding one means adding a module here and calling it — the wording never
|
|
//! leaks into the rest of the app, and changing a mail is a change to one file
|
|
//! with no logic in it. What every mail shares — the block of facts, the
|
|
//! signature, the frame — lives here so the six of them cannot drift apart.
|
|
|
|
pub mod reservation_approved;
|
|
pub mod reservation_bikes_changed;
|
|
pub mod reservation_cancelled;
|
|
pub mod reservation_period_changed;
|
|
pub mod reservation_shared;
|
|
pub mod reservation_updated;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
use crate::core::models::reservation::Reservation;
|
|
|
|
/// Who to ask about anything the mail does not answer
|
|
const BOT_URL: &str = "https://t.me/Logistique_Agepoly_Bot";
|
|
const BOT_HANDLE: &str = "@Logistique_Agepoly_Bot";
|
|
|
|
/// The facts every mail about a reservation shows.
|
|
///
|
|
/// Built from the reservation as stored plus the names of the bikes it holds:
|
|
/// a mail reads nothing itself, so writing one is only a matter of words.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ReservationSummary {
|
|
pub id: i32,
|
|
pub unit: String,
|
|
pub start_time: DateTime<Utc>,
|
|
pub end_time: DateTime<Utc>,
|
|
pub bikes: Vec<String>,
|
|
}
|
|
|
|
impl ReservationSummary {
|
|
pub fn new(reservation: &Reservation, bikes: Vec<String>) -> Self {
|
|
ReservationSummary {
|
|
id: reservation.id,
|
|
unit: reservation.unit.label().to_owned(),
|
|
start_time: reservation.start_time,
|
|
end_time: reservation.end_time,
|
|
bikes,
|
|
}
|
|
}
|
|
|
|
pub fn period(&self) -> String {
|
|
format!(
|
|
"du {} au {}",
|
|
format_moment(self.start_time),
|
|
format_moment(self.end_time)
|
|
)
|
|
}
|
|
|
|
pub fn bikes(&self) -> String {
|
|
if self.bikes.is_empty() {
|
|
"—".to_owned()
|
|
} else {
|
|
self.bikes.join(", ")
|
|
}
|
|
}
|
|
|
|
/// The three lines every mail repeats, so the reader never has to look the
|
|
/// reservation up to know which one is being talked about
|
|
pub fn html_details(&self) -> String {
|
|
format!(
|
|
"<ul>\
|
|
<li><strong>Association :</strong> {unit}</li>\
|
|
<li><strong>Période :</strong> {period}</li>\
|
|
<li><strong>Cargobikes réservés :</strong> {bikes}</li>\
|
|
</ul>",
|
|
unit = escape(&self.unit),
|
|
period = escape(&self.period()),
|
|
bikes = escape(&self.bikes()),
|
|
)
|
|
}
|
|
|
|
pub fn text_details(&self) -> String {
|
|
format!(
|
|
"- Association : {}\n- Période : {}\n- Cargobikes réservés : {}",
|
|
self.unit,
|
|
self.period(),
|
|
self.bikes()
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The closing every mail ends on
|
|
pub fn signature_html() -> String {
|
|
format!(
|
|
"<p>Pour toute question, n'hésitez pas à contacter le bot logistique \
|
|
<a href=\"{BOT_URL}\">{BOT_HANDLE}</a>.</p>\
|
|
<p>Cordialement,<br>L'équipe AGEPoly.</p>"
|
|
)
|
|
}
|
|
|
|
pub fn signature_text() -> String {
|
|
format!(
|
|
"Pour toute question, n'hésitez pas à contacter le bot logistique {BOT_HANDLE} \
|
|
({BOT_URL}).\n\nCordialement,\nL'équipe AGEPoly."
|
|
)
|
|
}
|
|
|
|
/// The four characters an HTML body reads as markup
|
|
pub fn escape(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
/// Swiss local time, in the form the mails use
|
|
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()
|
|
}
|
|
|
|
/// The plain frame every mail is poured into: a readable column, the system
|
|
/// font, and nothing a mail client has to fetch.
|
|
pub fn wrap(body: &str) -> String {
|
|
format!(
|
|
"<div style=\"font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; \
|
|
font-size: 15px; line-height: 1.5; color: #1a1a1a; max-width: 40rem;\">{body}</div>"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub mod tests {
|
|
use chrono::TimeZone;
|
|
|
|
use super::*;
|
|
|
|
/// The fixture every mail test renders
|
|
pub fn summary() -> ReservationSummary {
|
|
ReservationSummary {
|
|
id: 65,
|
|
unit: "AGEPoly 2".to_owned(),
|
|
start_time: Utc.with_ymd_and_hms(2026, 8, 18, 22, 0, 0).unwrap(),
|
|
end_time: Utc.with_ymd_and_hms(2026, 8, 24, 22, 0, 0).unwrap(),
|
|
bikes: vec!["3000".to_owned()],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_period_is_written_in_local_time() {
|
|
// 22:00 UTC is midnight the next day in Lausanne
|
|
assert_eq!(
|
|
summary().period(),
|
|
"du 19/08/2026 00:00 au 25/08/2026 00:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn markup_in_a_unit_name_is_escaped() {
|
|
let mut summary = summary();
|
|
summary.unit = "<b>Fake</b> & Co".to_owned();
|
|
let details = summary.html_details();
|
|
|
|
assert!(details.contains("<b>Fake</b> & Co"));
|
|
assert!(details.contains("<strong>Association :</strong>"));
|
|
}
|
|
}
|