189 lines
8.4 KiB
Rust
189 lines
8.4 KiB
Rust
//! 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<String> {
|
|
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/<script>"] {
|
|
assert_eq!(normalize_base_url(url), None, "url: {:?}", url);
|
|
}
|
|
}
|
|
}
|