diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 9f459f9..b4933b6 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -53,5 +53,7 @@ paths: - Server-side helpers return `AppError`; add a `From` impl in `src/error.rs` for new error sources (ssr-only ones behind `#[cfg(feature = "ssr")]`). The `From` impls already log the error. - Log with the `log` macros (`info!`, `warn!`, `error!`), not `println!`. -- Send mail through `Mailer::send_mail` (queued to a background sender thread), never directly from a request. Templates are `message` rows keyed by `MessageType` with `#date#` and `#summary#` placeholders. +- Send mail through `Mailer::send_mail` (queued to a background sender thread), never directly from a request. Templates are `message` rows keyed by `MessageType` with `#date#`, `#summary#` and `#link#` placeholders; the link to the booking page is added with `MailMessage::with_link`, built from `get_base_url()`. +- `get_booking` and `cancel_booking` are public on purpose: the uuid of a booking is the secret. They must never return or log more about the customer than the e-mails contain, and must answer an unknown uuid the same way as a malformed one. Anything that changes a booking stays a POST behind a button — mail clients open links on their own. +- Times of bookings are naive local times; compare them with `Local::now().naive_local()`. - A new `[section]`/key in `config.toml` needs the struct in `src/config.rs` and an entry in `config_sample.toml`. diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 626a270..a6a3ebc 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -39,7 +39,7 @@ paths: - Every user-visible string goes through `trl("English text")`; never hard-code Czech (or any text) in a view. The English text is the key and the fallback. - When you add or change a key, add the Czech translation to the `cs` map in `src/locales/catalogues.rs`. This includes validation `message`s and `ApiResponse::Error` strings from the backend. -- `trl()` reads the language from context, so in an event handler it silently returns the English key. Translate in the component body or in the view; keep a key in a signal and translate it when rendering (see `ImageUpload` in `src/pages/appearance_settings.rs`). +- `trl()` works everywhere: in the browser it falls back to the languages of the browser where context is not available (event handlers), on the server it reads `Accept-Language` of the current request — so a text built in a server function (e.g. a sentence put into an e-mail) is in the language of whoever made that request. - `trl()` returns a closure — use it directly as `{trl("...")}` or attribute value; call it (`trl("...")()`) when a `String` is needed. - Dates shown to users go through `loc_date()`, weekday names through `show_day()`. diff --git a/CLAUDE.md b/CLAUDE.md index 9e93c0e..2a6e427 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,15 +43,16 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[ - `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal). - `src/backend/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser. - `src/backend/rich_text.rs` — the text under the banner: `sanitize_text` reduces its HTML to a few allowed tags before it is stored (it is shown to every visitor), `normalize_link` prepares link addresses. +- `src/backend/cancellation.rs` — cancelling of a booking by the customer: the deadline rule, the link to the booking page and putting it into e-mails. - `src/backend/layout.rs` — layout options of the public booking form (`LayoutOptions`), which become classes on the page wrapper. - `src/backend/color.rs` — colours of the public page set by the customer (`Colors`), contrast checks and the ready-made palettes; shared by server and browser. - `src/locales/` — `trl()` and the translation catalogue. - `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper. -- `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`. +- `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `public_url` config key, the `TZ` variable, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`. - `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it. App styles are plain CSS files in `assets/css/`, linked in `src/components/header.rs`. - `style/main.scss` is compiled to `/pkg/rezervator.css`, but nothing links that file, so styles written there have no effect. -Routes: `/` public booking form, `/login`, `/admin`, `/admin/settings`, `/admin/mail_settings`, `/admin/bookings`, `/admin/customers`, `/admin/appearance`. +Routes: `/` public booking form, `/booking/` the customer's page of a booking (linked from e-mails, the uuid is the only protection), `/login`, `/admin`, `/admin/settings`, `/admin/mail_settings`, `/admin/bookings`, `/admin/customers`, `/admin/appearance`. ## Domain model diff --git a/Dockerfile b/Dockerfile index 026373d..f1a8387 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN LEPTOS_OUTPUT_NAME="rezervator-$(tr -dc a-z0-9 `). +The config gets `public_url = 'https://'`, the address used for links in e-mails (the page where a customer can view and cancel a booking). Customers created before this setting existed have no `public_url`; their links use the address of the request, which is fine behind Traefik or an nginx server block of the domain. Add the line to `customers//config.toml` and run `upgrade ` if you want it explicit. + +Containers run in the time zone set by `REZERVATOR_TZ` in `.env` (default `Europe/Prague`). It takes effect when a container is created, that is on `add`, `upgrade` and `restore`. + `./rezervator.sh list` shows all customers, their ports and the state of their containers. ## Upgrading diff --git a/deploy/config.template.toml b/deploy/config.template.toml index 2c6a82e..17ec807 100644 --- a/deploy/config.template.toml +++ b/deploy/config.template.toml @@ -8,6 +8,8 @@ key = '@SESSION_KEY@' [network] bind_ip = '0.0.0.0' port = 3000 +# Address for links in e-mails (the page where a customer can view and cancel a booking) +public_url = 'https://@DOMAIN@' [database] user = '@DB_USER@' diff --git a/deploy/rezervator.sh b/deploy/rezervator.sh index b345cef..1f6c4f1 100755 --- a/deploy/rezervator.sh +++ b/deploy/rezervator.sh @@ -64,6 +64,7 @@ load_env() { NETWORK="$PREFIX" CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}" BASE_PORT="${BASE_PORT:-3100}" + TIME_ZONE="${REZERVATOR_TZ:-Europe/Prague}" case "$PROXY" in traefik|nginx) ;; *) die "REZERVATOR_PROXY must be traefik or nginx" ;; esac case "$POSTGRES" in container|host) ;; *) die "REZERVATOR_POSTGRES must be container or host" ;; esac @@ -230,6 +231,8 @@ run_container() { --network "$NETWORK" --volume "$CUSTOMERS_DIR/$name/config.toml:/app/config/config.toml:ro" --volume "$(volume_name "$name"):/app/data" + # Times of bookings are local times; the deadline for cancelling is compared with the clock + --env "TZ=$TIME_ZONE" --label "rezervator.stack=$PREFIX" --label "rezervator.customer=$name" --label "rezervator.domain=$domain" @@ -392,6 +395,7 @@ SQL -e "s|@DB_PORT@|$PG_PORT|" \ -e "s|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \ -e "s|@MAIL_FROM@|$mail_from|" \ + -e "s|@DOMAIN@|$domain|" \ "$DEPLOY_DIR/config.template.toml" > "$dir/config.toml" # The directory is closed to other users; the application in the container must be able to read the file chmod 644 "$dir/config.toml" diff --git a/migrations/10_customer_cancel.sql b/migrations/10_customer_cancel.sql new file mode 100644 index 0000000..ed2a5ad --- /dev/null +++ b/migrations/10_customer_cancel.sql @@ -0,0 +1,4 @@ +-- Customers can cancel their bookings through a link in the e-mail +-- How many hours before the start of a booking the customer may still cancel it, 0 = until the start +ALTER TABLE company ADD cancel_hours INTEGER NOT NULL DEFAULT 0; +ALTER TABLE reservation_sum ADD canceled_by_customer BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/11_customer_canceled_message.sql b/migrations/11_customer_canceled_message.sql new file mode 100644 index 0000000..c83e986 --- /dev/null +++ b/migrations/11_customer_canceled_message.sql @@ -0,0 +1,4 @@ +-- no-transaction +-- E-mail for the staff about a booking cancelled by the customer. A new enum value can't be +-- added inside a transaction on older PostgreSQL versions, hence the separate file. +ALTER TYPE message_type ADD VALUE IF NOT EXISTS 'CustomerCanceled'; diff --git a/src/app.rs b/src/app.rs index 1738cf6..5809df5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,6 +16,7 @@ use crate::pages::customers::Customers; use crate::pages::login::Login; use crate::pages::mail_settings::MailSettings; use crate::pages::public::PublicPage; +use crate::pages::booking::BookingPage; #[derive(Clone, Copy, Debug)] pub struct MenuHelper { @@ -97,6 +98,7 @@ pub fn App() -> impl IntoView {
}/> + }/> }/> diff --git a/src/backend/cancellation.rs b/src/backend/cancellation.rs new file mode 100644 index 0000000..60e9755 --- /dev/null +++ b/src/backend/cancellation.rs @@ -0,0 +1,188 @@ +//! 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/