//! Cancelling of a booking by the customer, through the link sent in e-mails. use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::backend::data::ReservationState; /// Longest deadline that can be set, in hours before the start of a booking (30 days) pub const MAX_CANCEL_HOURS: i32 = 720; /// Placeholder of the link to the booking page in mail templates pub const LINK_PLACEHOLDER: &str = "#link#"; /// Whether the customer may cancel a booking #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CancelStatus { /// It can be cancelled until the given time Allowed(NaiveDateTime), AlreadyCanceled, /// It could be cancelled until the given time, which has passed TooLate(NaiveDateTime), } /// A booking starts with the earliest of its items. One without items (which should not exist) /// counts from the beginning of its day. pub fn booking_start(date: NaiveDate, item_starts: &[NaiveTime]) -> NaiveDateTime { date.and_time(item_starts.iter().min().copied().unwrap_or(NaiveTime::MIN)) } /// Last moment when the customer may cancel: the given number of hours before the start. /// A negative or too large setting is treated as the nearest allowed one. pub fn cancel_deadline(start: NaiveDateTime, cancel_hours: i32) -> NaiveDateTime { start - Duration::hours(cancel_hours.clamp(0, MAX_CANCEL_HOURS) as i64) } /// Bookings waiting for approval and approved ones can be cancelled until the deadline pub fn cancel_status(state: &ReservationState, start: NaiveDateTime, cancel_hours: i32, now: NaiveDateTime) -> CancelStatus { let deadline = cancel_deadline(start, cancel_hours); if *state == ReservationState::Canceled { CancelStatus::AlreadyCanceled } else if now < deadline { CancelStatus::Allowed(deadline) } else { CancelStatus::TooLate(deadline) } } /// Address of the page of a booking. The uuid is the only secret that protects it. pub fn booking_link(base_url: &str, uuid: &str) -> String { format!("{}/booking/{}", base_url.trim_end_matches('/'), uuid) } /// Puts the link to the booking page into the text of an e-mail: in place of every `#link#`. /// A text without the placeholder gets the link at its end, introduced by the given sentence, /// when there is one; otherwise it is left as it is. pub fn insert_link(text: &str, link: &str, intro_when_missing: Option<&str>) -> String { if text.contains(LINK_PLACEHOLDER) { return text.replace(LINK_PLACEHOLDER, link) } match intro_when_missing { Some(intro) => format!("{}\n\n{}\n{}", text.trim_end(), intro, link), None => text.to_string(), } } /// Cleans up the public address from the config: no trailing slash, and only `http(s)://host[:port][/path]`. /// The address gets into e-mails, so anything suspicious is refused. pub fn normalize_base_url(url: &str) -> Option { let url = url.trim().trim_end_matches('/'); let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?; if rest.is_empty() || rest.starts_with('/') || !rest.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '/' | '[' | ']')) { return None } Some(url.to_string()) } #[cfg(test)] mod tests { use super::*; fn date(day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 10, day).unwrap() } fn time(hour: u32, minute: u32) -> NaiveTime { NaiveTime::from_hms_opt(hour, minute, 0).unwrap() } fn at(day: u32, hour: u32, minute: u32) -> NaiveDateTime { date(day).and_time(time(hour, minute)) } #[test] fn booking_starts_with_earliest_item() { assert_eq!(booking_start(date(20), &[time(18, 0), time(9, 30), time(14, 0)]), at(20, 9, 30)); assert_eq!(booking_start(date(20), &[time(18, 0)]), at(20, 18, 0)); assert_eq!(booking_start(date(20), &[]), at(20, 0, 0)); } #[test] fn deadline_is_hours_before_start() { assert_eq!(cancel_deadline(at(20, 18, 0), 0), at(20, 18, 0)); assert_eq!(cancel_deadline(at(20, 18, 0), 2), at(20, 16, 0)); // Over midnight assert_eq!(cancel_deadline(at(20, 9, 0), 24), at(19, 9, 0)); assert_eq!(cancel_deadline(at(20, 1, 0), 3), at(19, 22, 0)); } #[test] fn deadline_ignores_invalid_settings() { assert_eq!(cancel_deadline(at(20, 18, 0), -5), at(20, 18, 0)); assert_eq!(cancel_deadline(at(20, 18, 0), i32::MAX), at(20, 18, 0) - Duration::hours(MAX_CANCEL_HOURS as i64)); } #[test] fn new_and_approved_bookings_can_be_cancelled_before_deadline() { for state in [ReservationState::New, ReservationState::Approved] { assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 17, 59)), CancelStatus::Allowed(at(20, 18, 0))); assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(19, 17, 59)), CancelStatus::Allowed(at(19, 18, 0))); assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(10, 0, 0)), CancelStatus::Allowed(at(19, 18, 0))); } } #[test] fn bookings_cannot_be_cancelled_from_the_deadline_on() { for state in [ReservationState::New, ReservationState::Approved] { // Exactly at the start, after it, and in the past assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 18, 0)), CancelStatus::TooLate(at(20, 18, 0))); assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 18, 1)), CancelStatus::TooLate(at(20, 18, 0))); assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(25, 0, 0)), CancelStatus::TooLate(at(20, 18, 0))); // Inside the deadline, although the booking has not started yet assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(19, 18, 0)), CancelStatus::TooLate(at(19, 18, 0))); assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(20, 10, 0)), CancelStatus::TooLate(at(19, 18, 0))); } } #[test] fn cancelled_booking_stays_cancelled() { assert_eq!(cancel_status(&ReservationState::Canceled, at(20, 18, 0), 0, at(1, 0, 0)), CancelStatus::AlreadyCanceled); assert_eq!(cancel_status(&ReservationState::Canceled, at(20, 18, 0), 0, at(25, 0, 0)), CancelStatus::AlreadyCanceled); } #[test] fn booking_link_joins_address_and_uuid() { let uuid = "0b6ff0a4-2f4b-4c57-9d0a-0d4f7f0f3b11"; assert_eq!(booking_link("https://booking.example.com", uuid), format!("https://booking.example.com/booking/{}", uuid)); assert_eq!(booking_link("https://booking.example.com/", uuid), format!("https://booking.example.com/booking/{}", uuid)); assert_eq!(booking_link("http://localhost:3000", uuid), format!("http://localhost:3000/booking/{}", uuid)); } #[test] fn insert_link_replaces_placeholder() { assert_eq!(insert_link("See #link# for details.", "https://x/booking/1", Some("Intro:")), "See https://x/booking/1 for details."); assert_eq!(insert_link("#link#\n#link#", "L", None), "L\nL"); } #[test] fn insert_link_appends_when_placeholder_is_missing() { assert_eq!(insert_link("Thank you.\n", "https://x/booking/1", Some("Your booking:")), "Thank you.\n\nYour booking:\nhttps://x/booking/1"); assert_eq!(insert_link("", "L", Some("Intro:")), "\n\nIntro:\nL"); } #[test] fn insert_link_leaves_text_without_intro() { assert_eq!(insert_link("Thank you.\n", "https://x/booking/1", None), "Thank you.\n"); } #[test] fn base_url_is_normalized() { assert_eq!(normalize_base_url("https://booking.example.com"), Some("https://booking.example.com".to_string())); assert_eq!(normalize_base_url(" https://booking.example.com/ "), Some("https://booking.example.com".to_string())); assert_eq!(normalize_base_url("http://localhost:3000"), Some("http://localhost:3000".to_string())); assert_eq!(normalize_base_url("https://example.com/app"), Some("https://example.com/app".to_string())); assert_eq!(normalize_base_url("http://[::1]:3000"), Some("http://[::1]:3000".to_string())); } #[test] fn base_url_rejects_other_values() { for url in ["", "booking.example.com", "ftp://example.com", "https://", "https:///path", "javascript:alert(1)", "https://example.com/?x=1", "https://exa mple.com", "https://example.com\nBcc: x@y.z", "https://user@example.com", "https://example.com/