Customers can view and cancel their booking through a link in the e-mail. Fixes #18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- 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!`.
|
- 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`.
|
- A new `[section]`/key in `config.toml` needs the struct in `src/config.rs` and an entry in `config_sample.toml`.
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- `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()`.
|
- Dates shown to users go through `loc_date()`, weekday names through `show_day()`.
|
||||||
|
|
||||||
|
|||||||
@@ -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/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/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/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/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/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/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.
|
- `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`.
|
- `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.
|
- `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/<uuid>` 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
|
## Domain model
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -33,7 +33,7 @@ RUN LEPTOS_OUTPUT_NAME="rezervator-$(tr -dc a-z0-9 </dev/urandom | head -c 10)"
|
|||||||
FROM debian:bookworm-slim AS runtime
|
FROM debian:bookworm-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update -y \
|
RUN apt-get update -y \
|
||||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
&& apt-get install -y --no-install-recommends openssl ca-certificates tzdata \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& apt-get clean -y \
|
&& apt-get clean -y \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -44,7 +44,7 @@ COPY --from=builder /app/target/release/rezervator /app/
|
|||||||
# /target/site contains our JS/WASM/CSS, etc.
|
# /target/site contains our JS/WASM/CSS, etc.
|
||||||
COPY --from=builder /app/target/site /app/target/site
|
COPY --from=builder /app/target/site /app/target/site
|
||||||
|
|
||||||
# Set any required env variables and
|
# Bookings are in local time: set TZ (e.g. TZ=Europe/Prague), otherwise the clock is UTC
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# The config file holds passwords and the session key. Mount it to /app/config, never under
|
# The config file holds passwords and the session key. Mount it to /app/config, never under
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ The server reads `config.toml` from the working directory, or from the path give
|
|||||||
|
|
||||||
The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it.
|
The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it.
|
||||||
|
|
||||||
|
## Links in e-mails
|
||||||
|
|
||||||
|
E-mails about a booking contain a link to a page where the customer can view the booking and cancel it. Set `public_url` in the `[network]` section of the config to the address under which the application is reached from outside (e.g. `https://booking.example.com`). Without it the links use the address of the request that caused the e-mail, as the reverse proxy reports it; that is safe only when the proxy passes requests for your domain only, otherwise somebody could have e-mails sent with links to another site.
|
||||||
|
|
||||||
|
In the mail templates the link is written as `#link#`. E-mails for the customer about a new and an approved booking get it at the end when their template does not contain it. How long before its start a booking can be cancelled is set with the company data in the admin portal.
|
||||||
|
|
||||||
|
Times of bookings are local times, and the deadline for cancelling is compared with the clock of the server. Make sure the server runs in the time zone of your customers.
|
||||||
|
|
||||||
## Database connections
|
## Database connections
|
||||||
|
|
||||||
Each instance opens database connections on demand, up to `max_connections` in the `[database]` section of the config (10 by default), and closes the ones that stay idle. When many instances share one PostgreSQL server, the sum of their limits must stay below the server's own `max_connections` (100 by default): lower the limit per instance, raise it on the server, or put PgBouncer in front of PostgreSQL.
|
Each instance opens database connections on demand, up to `max_connections` in the `[database]` section of the config (10 by default), and closes the ones that stay idle. When many instances share one PostgreSQL server, the sum of their limits must stay below the server's own `max_connections` (100 by default): lower the limit per instance, raise it on the server, or put PgBouncer in front of PostgreSQL.
|
||||||
@@ -56,6 +64,8 @@ Mount the config file to `/app/config/config.toml` and a persistent volume for t
|
|||||||
|
|
||||||
Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container.
|
Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container.
|
||||||
|
|
||||||
|
The clock in the container is UTC unless you set the time zone, e.g. `-e TZ=Europe/Prague`. Bookings are in local time, so set it.
|
||||||
|
|
||||||
Older versions of the image expected the config in `/app/target/site/data/config.toml`, where it could be downloaded from `/data/config.toml`. If you ran such a container, move the file and change the database password, the SMTP password and the session key.
|
Older versions of the image expected the config in `/app/target/site/data/config.toml`, where it could be downloaded from `/data/config.toml`. If you ran such a container, move the file and change the database password, the SMTP password and the session key.
|
||||||
|
|
||||||
## Health check
|
## Health check
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ key = '------------- Secure session key, 64 character length -------------'
|
|||||||
[network]
|
[network]
|
||||||
bind_ip = '127.0.0.1'
|
bind_ip = '127.0.0.1'
|
||||||
port = 3000
|
port = 3000
|
||||||
|
# Address under which the application is reached from outside. It is used for links in e-mails
|
||||||
|
# (the page where a customer can view and cancel a booking). Without it the address of the
|
||||||
|
# request is used, which is safe only behind a reverse proxy that passes just your domain.
|
||||||
|
#public_url = 'https://booking.example.com'
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
user = 'user'
|
user = 'user'
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ REZERVATOR_PREFIX=rezervator
|
|||||||
# Image the customer containers are started from
|
# Image the customer containers are started from
|
||||||
REZERVATOR_IMAGE=rezervator:latest
|
REZERVATOR_IMAGE=rezervator:latest
|
||||||
|
|
||||||
|
# Time zone of the customers. Bookings are made in local time, and the application compares
|
||||||
|
# them with its clock (today's date, the deadline for cancelling a booking).
|
||||||
|
REZERVATOR_TZ=Europe/Prague
|
||||||
|
|
||||||
# Reverse proxy in front of the customer containers:
|
# Reverse proxy in front of the customer containers:
|
||||||
# traefik runs as a container of this stack, routes by domain, can issue certificates itself
|
# traefik runs as a container of this stack, routes by domain, can issue certificates itself
|
||||||
# nginx the nginx already installed on this host. Every customer container listens on
|
# nginx the nginx already installed on this host. Every customer container listens on
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ This creates the database and its role, writes `customers/alpha/config.toml` wit
|
|||||||
|
|
||||||
An optional third argument sets the sender address of e-mails (default `noreply@<domain>`).
|
An optional third argument sets the sender address of e-mails (default `noreply@<domain>`).
|
||||||
|
|
||||||
|
The config gets `public_url = 'https://<domain>'`, 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/<name>/config.toml` and run `upgrade <name>` 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.
|
`./rezervator.sh list` shows all customers, their ports and the state of their containers.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ key = '@SESSION_KEY@'
|
|||||||
[network]
|
[network]
|
||||||
bind_ip = '0.0.0.0'
|
bind_ip = '0.0.0.0'
|
||||||
port = 3000
|
port = 3000
|
||||||
|
# Address for links in e-mails (the page where a customer can view and cancel a booking)
|
||||||
|
public_url = 'https://@DOMAIN@'
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
user = '@DB_USER@'
|
user = '@DB_USER@'
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ load_env() {
|
|||||||
NETWORK="$PREFIX"
|
NETWORK="$PREFIX"
|
||||||
CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}"
|
CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}"
|
||||||
BASE_PORT="${BASE_PORT:-3100}"
|
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 "$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
|
case "$POSTGRES" in container|host) ;; *) die "REZERVATOR_POSTGRES must be container or host" ;; esac
|
||||||
@@ -230,6 +231,8 @@ run_container() {
|
|||||||
--network "$NETWORK"
|
--network "$NETWORK"
|
||||||
--volume "$CUSTOMERS_DIR/$name/config.toml:/app/config/config.toml:ro"
|
--volume "$CUSTOMERS_DIR/$name/config.toml:/app/config/config.toml:ro"
|
||||||
--volume "$(volume_name "$name"):/app/data"
|
--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.stack=$PREFIX"
|
||||||
--label "rezervator.customer=$name"
|
--label "rezervator.customer=$name"
|
||||||
--label "rezervator.domain=$domain"
|
--label "rezervator.domain=$domain"
|
||||||
@@ -392,6 +395,7 @@ SQL
|
|||||||
-e "s|@DB_PORT@|$PG_PORT|" \
|
-e "s|@DB_PORT@|$PG_PORT|" \
|
||||||
-e "s|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \
|
-e "s|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \
|
||||||
-e "s|@MAIL_FROM@|$mail_from|" \
|
-e "s|@MAIL_FROM@|$mail_from|" \
|
||||||
|
-e "s|@DOMAIN@|$domain|" \
|
||||||
"$DEPLOY_DIR/config.template.toml" > "$dir/config.toml"
|
"$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
|
# The directory is closed to other users; the application in the container must be able to read the file
|
||||||
chmod 644 "$dir/config.toml"
|
chmod 644 "$dir/config.toml"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -16,6 +16,7 @@ use crate::pages::customers::Customers;
|
|||||||
use crate::pages::login::Login;
|
use crate::pages::login::Login;
|
||||||
use crate::pages::mail_settings::MailSettings;
|
use crate::pages::mail_settings::MailSettings;
|
||||||
use crate::pages::public::PublicPage;
|
use crate::pages::public::PublicPage;
|
||||||
|
use crate::pages::booking::BookingPage;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct MenuHelper {
|
pub struct MenuHelper {
|
||||||
@@ -97,6 +98,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<main>
|
<main>
|
||||||
<Routes fallback=|| "Page not found.">
|
<Routes fallback=|| "Page not found.">
|
||||||
<Route path=path!("") view=|| view! { <PublicPage/> }/>
|
<Route path=path!("") view=|| view! { <PublicPage/> }/>
|
||||||
|
<Route path=path!("booking/:uuid") view=|| view! { <BookingPage/> }/>
|
||||||
<Route path=path!("login") view=|| view! { <Login/> }/>
|
<Route path=path!("login") view=|| view! { <Login/> }/>
|
||||||
<Route path=path!("admin") view=|| view! {
|
<Route path=path!("admin") view=|| view! {
|
||||||
<AdminPortal>
|
<AdminPortal>
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,10 +46,15 @@ pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerF
|
|||||||
|
|
||||||
perm_check!(is_admin);
|
perm_check!(is_admin);
|
||||||
|
|
||||||
|
// The form validates in the browser only
|
||||||
|
if company.validate().is_err() {
|
||||||
|
return Ok(ApiResponse::Error("Invalid company data".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
let pool = get_pool().await?;
|
let pool = get_pool().await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE company SET name = $1, street = $2, house_number = $3, zip_code = $4, city = $5 \
|
"UPDATE company SET name = $1, street = $2, house_number = $3, zip_code = $4, city = $5, cancel_hours = $7 \
|
||||||
WHERE id = $6")
|
WHERE id = $6")
|
||||||
.bind(company.name.clone())
|
.bind(company.name.clone())
|
||||||
.bind(company.street.clone())
|
.bind(company.street.clone())
|
||||||
@@ -57,6 +62,7 @@ pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerF
|
|||||||
.bind(company.zip_code.clone())
|
.bind(company.zip_code.clone())
|
||||||
.bind(company.city.clone())
|
.bind(company.city.clone())
|
||||||
.bind(company.id())
|
.bind(company.id())
|
||||||
|
.bind(company.cancel_hours)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ pub struct Company {
|
|||||||
pub house_number: String,
|
pub house_number: String,
|
||||||
pub zip_code: String,
|
pub zip_code: String,
|
||||||
pub city: String,
|
pub city: String,
|
||||||
|
/// How many hours before the start of a booking the customer may still cancel it, 0 = until the start
|
||||||
|
#[serde(default)]
|
||||||
|
#[validate(range(min = 0, max = 720, message = "Cancellation deadline must be between 0 and 720 hours"))]
|
||||||
|
pub cancel_hours: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Company {
|
impl Company {
|
||||||
@@ -666,6 +670,9 @@ pub struct ReservationSum {
|
|||||||
pub date_create: NaiveDate,
|
pub date_create: NaiveDate,
|
||||||
pub edited_by: Option<i32>,
|
pub edited_by: Option<i32>,
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
|
/// Cancelled through the link in the e-mail, not by the staff
|
||||||
|
#[serde(default)]
|
||||||
|
pub canceled_by_customer: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReservationSum {
|
impl ReservationSum {
|
||||||
@@ -681,6 +688,28 @@ pub struct ResSumWithItems {
|
|||||||
pub reservations: Vec<ResWithProperty>
|
pub reservations: Vec<ResWithProperty>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One booked item as the customer sees it on the page of their booking
|
||||||
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PublicBookingItem {
|
||||||
|
pub name: String,
|
||||||
|
pub from: NaiveTime,
|
||||||
|
pub to: NaiveTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A booking as shown to whoever has its link. It has nothing more than the e-mails about
|
||||||
|
/// the booking: no name, e-mail address or phone number of the customer.
|
||||||
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PublicBooking {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub items: Vec<PublicBookingItem>,
|
||||||
|
pub price: Decimal,
|
||||||
|
pub state: ReservationState,
|
||||||
|
/// The customer may cancel it now
|
||||||
|
pub can_cancel: bool,
|
||||||
|
/// Until when it can (or could) be cancelled
|
||||||
|
pub cancel_until: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
pub struct ResAllView {
|
pub struct ResAllView {
|
||||||
@@ -701,6 +730,8 @@ pub enum MessageType {
|
|||||||
NewReservationCust,
|
NewReservationCust,
|
||||||
ReservationApp,
|
ReservationApp,
|
||||||
ReservationCanceled,
|
ReservationCanceled,
|
||||||
|
/// For the staff: the customer cancelled the booking
|
||||||
|
CustomerCanceled,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for MessageType {
|
impl Display for MessageType {
|
||||||
@@ -710,6 +741,7 @@ impl Display for MessageType {
|
|||||||
MessageType::NewReservationCust => {"NewReservationCust"}
|
MessageType::NewReservationCust => {"NewReservationCust"}
|
||||||
MessageType::ReservationApp => {"ReservationApp"}
|
MessageType::ReservationApp => {"ReservationApp"}
|
||||||
MessageType::ReservationCanceled => {"ReservationCanceled"}
|
MessageType::ReservationCanceled => {"ReservationCanceled"}
|
||||||
|
MessageType::CustomerCanceled => {"CustomerCanceled"}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-2
@@ -14,6 +14,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
use crate::config::Mailing;
|
use crate::config::Mailing;
|
||||||
use crate::config::MailTransport;
|
use crate::config::MailTransport;
|
||||||
use crate::backend::data::ResSumWithItems;
|
use crate::backend::data::ResSumWithItems;
|
||||||
|
use crate::backend::cancellation::insert_link;
|
||||||
use lettre::message::Message as LettreMessage;
|
use lettre::message::Message as LettreMessage;
|
||||||
use lettre::{FileTransport, SmtpTransport, Transport};
|
use lettre::{FileTransport, SmtpTransport, Transport};
|
||||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||||
@@ -29,9 +30,25 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subject and text a new template starts with. The first templates start empty and are
|
||||||
|
/// written by the administrator; one added by an upgrade must be usable right away,
|
||||||
|
/// otherwise existing installations would send empty e-mails.
|
||||||
|
fn initial_message(msg_type: &MessageType) -> (&'static str, &'static str) {
|
||||||
|
match msg_type {
|
||||||
|
MessageType::CustomerCanceled => (
|
||||||
|
"Booking cancelled by the customer",
|
||||||
|
"The customer cancelled a booking.\n\nDate: #date#\n#summary#\n\n#link#"),
|
||||||
|
_ => ("", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn init_message(msg_type: &MessageType, pool: &PgPool) -> Result<(), Error> {
|
pub async fn init_message(msg_type: &MessageType, pool: &PgPool) -> Result<(), Error> {
|
||||||
query("INSERT INTO message(msg_type, subject, text) VALUES($1, '', '')")
|
let (subject, text) = initial_message(msg_type);
|
||||||
|
|
||||||
|
query("INSERT INTO message(msg_type, subject, text) VALUES($1, $2, $3)")
|
||||||
.bind(msg_type)
|
.bind(msg_type)
|
||||||
|
.bind(subject)
|
||||||
|
.bind(text)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -43,7 +60,8 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
MessageType::NewReservation,
|
MessageType::NewReservation,
|
||||||
MessageType::NewReservationCust,
|
MessageType::NewReservationCust,
|
||||||
MessageType::ReservationApp,
|
MessageType::ReservationApp,
|
||||||
MessageType::ReservationCanceled];
|
MessageType::ReservationCanceled,
|
||||||
|
MessageType::CustomerCanceled];
|
||||||
|
|
||||||
for msg_type in types {
|
for msg_type in types {
|
||||||
let msg = message_for_type(&msg_type, pool).await;
|
let msg = message_for_type(&msg_type, pool).await;
|
||||||
@@ -77,6 +95,14 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts the link to the page of the booking into the text: in place of `#link#`, or at the
|
||||||
|
/// end after the given sentence when the text has no placeholder. Without a sentence
|
||||||
|
/// a text without the placeholder stays as it is.
|
||||||
|
pub fn with_link(mut self, link: &str, intro_when_missing: Option<&str>) -> Self {
|
||||||
|
self.text = insert_link(&self.text, link, intro_when_missing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String {
|
fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String {
|
||||||
text
|
text
|
||||||
.replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string())
|
.replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string())
|
||||||
@@ -330,6 +356,33 @@ mod tests {
|
|||||||
assert_eq!(mail.text, "Items: .");
|
assert_eq!(mail.text, "Items: .");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_added_by_upgrade_is_not_empty() {
|
||||||
|
let (subject, text) = initial_message(&MessageType::CustomerCanceled);
|
||||||
|
assert!(!subject.is_empty());
|
||||||
|
assert!(text.contains("#date#") && text.contains("#summary#") && text.contains("#link#"));
|
||||||
|
assert_eq!(initial_message(&MessageType::NewReservation), ("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mail_message_link_is_inserted_or_appended() {
|
||||||
|
let with_placeholder = MailMessage::new("a@b.cz".to_string(), "c@d.cz".to_string(),
|
||||||
|
message("Booking", "Date: #date#\nDetail: #link#"), &reservation())
|
||||||
|
.with_link("https://x/booking/1", Some("Your booking:"));
|
||||||
|
assert!(with_placeholder.text.ends_with("Detail: https://x/booking/1"), "text: {}", with_placeholder.text);
|
||||||
|
assert!(!with_placeholder.text.contains("Your booking:"));
|
||||||
|
|
||||||
|
let without = MailMessage::new("a@b.cz".to_string(), "c@d.cz".to_string(),
|
||||||
|
message("Booking", "Thank you."), &reservation())
|
||||||
|
.with_link("https://x/booking/1", Some("Your booking:"));
|
||||||
|
assert_eq!(without.text, "Thank you.\n\nYour booking:\nhttps://x/booking/1");
|
||||||
|
|
||||||
|
let untouched = MailMessage::new("a@b.cz".to_string(), "c@d.cz".to_string(),
|
||||||
|
message("Booking", "Thank you."), &reservation())
|
||||||
|
.with_link("https://x/booking/1", None);
|
||||||
|
assert_eq!(untouched.text, "Thank you.");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mail_message_without_variables_is_unchanged() {
|
fn mail_message_without_variables_is_unchanged() {
|
||||||
let mail = MailMessage::new(
|
let mail = MailMessage::new(
|
||||||
|
|||||||
+29
-3
@@ -13,6 +13,7 @@ pub mod color;
|
|||||||
pub mod banner;
|
pub mod banner;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod rich_text;
|
pub mod rich_text;
|
||||||
|
pub mod cancellation;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|
||||||
@@ -68,18 +69,25 @@ cfg_if!{
|
|||||||
pub struct AppData {
|
pub struct AppData {
|
||||||
db_pool: PgPool,
|
db_pool: PgPool,
|
||||||
mailer: Mailer,
|
mailer: Mailer,
|
||||||
data_dir: PathBuf
|
data_dir: PathBuf,
|
||||||
|
public_url: Option<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppData {
|
impl AppData {
|
||||||
pub fn new(db_pool: PgPool, mailer: Mailer, data_dir: PathBuf) -> Self {
|
pub fn new(db_pool: PgPool, mailer: Mailer, data_dir: PathBuf, public_url: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db_pool,
|
db_pool,
|
||||||
mailer,
|
mailer,
|
||||||
data_dir
|
data_dir,
|
||||||
|
public_url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Address under which the application is reached from outside, when the config has it
|
||||||
|
pub fn public_url(&self) -> &Option<String> {
|
||||||
|
&self.public_url
|
||||||
|
}
|
||||||
|
|
||||||
/// Directory for files created at runtime, served under `/data`
|
/// Directory for files created at runtime, served under `/data`
|
||||||
pub fn data_dir(&self) -> &PathBuf {
|
pub fn data_dir(&self) -> &PathBuf {
|
||||||
&self.data_dir
|
&self.data_dir
|
||||||
@@ -94,6 +102,24 @@ cfg_if!{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Address for links sent in e-mails: `public_url` from the config, otherwise the address
|
||||||
|
/// the current request came to (as the reverse proxy reports it).
|
||||||
|
pub async fn get_base_url() -> Result<String, ServerFnError> {
|
||||||
|
use crate::backend::cancellation::normalize_base_url;
|
||||||
|
use leptos::prelude::use_context;
|
||||||
|
|
||||||
|
let data = extract::<Data<AppData>>().await?;
|
||||||
|
if let Some(url) = data.public_url() {
|
||||||
|
return Ok(url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = use_context::<leptos_actix::Request>().ok_or(ServerFnError::new("No request"))?;
|
||||||
|
let info = request.connection_info();
|
||||||
|
|
||||||
|
normalize_base_url(&format!("{}://{}", info.scheme(), info.host()))
|
||||||
|
.ok_or(ServerFnError::new("Can't determine public address"))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_pool() -> Result<PgPool, ServerFnError> {
|
pub async fn get_pool() -> Result<PgPool, ServerFnError> {
|
||||||
let data = extract::<Data<AppData>>().await?;
|
let data = extract::<Data<AppData>>().await?;
|
||||||
Ok(data.db_pool().clone())
|
Ok(data.db_pool().clone())
|
||||||
|
|||||||
+172
-8
@@ -1,6 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicFormData, ResSumWithItems, Page, PagedResponse};
|
use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicBooking, PublicFormData, ResSumWithItems, Page, PagedResponse};
|
||||||
use crate::components::data_form::ForValidation;
|
use crate::components::data_form::ForValidation;
|
||||||
use cfg_if::cfg_if;
|
use cfg_if::cfg_if;
|
||||||
use chrono::{NaiveDate, NaiveTime};
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
@@ -18,6 +18,10 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType, ChartData, ResAllView};
|
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType, ChartData, ResAllView};
|
||||||
use crate::backend::get_pool;
|
use crate::backend::get_pool;
|
||||||
use crate::backend::get_mailing;
|
use crate::backend::get_mailing;
|
||||||
|
use crate::backend::get_base_url;
|
||||||
|
use crate::backend::cancellation::{booking_link, booking_start, cancel_status, CancelStatus};
|
||||||
|
use crate::backend::data::{PublicBookingItem};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
use crate::backend::mail::MailMessage;
|
use crate::backend::mail::MailMessage;
|
||||||
use crate::backend::mail::get_message;
|
use crate::backend::mail::get_message;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -72,6 +76,61 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A booking by the uuid from its link, `None` when there is no such booking
|
||||||
|
async fn find_booking(uuid: &Uuid) -> Result<Option<ResSumWithItems>, ServerFnError> {
|
||||||
|
let pool = get_pool().await?;
|
||||||
|
let summary = query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE uuid = $1")
|
||||||
|
.bind(uuid)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(summary) = summary else {
|
||||||
|
return Ok(None)
|
||||||
|
};
|
||||||
|
let sum_id = summary.id();
|
||||||
|
let cust_id = summary.customer;
|
||||||
|
|
||||||
|
Ok(Some(ResSumWithItems {
|
||||||
|
summary,
|
||||||
|
customer: customer_for_reservation(cust_id, &pool).await?,
|
||||||
|
reservations: items_for_reservation(sum_id, &pool).await?
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the customer may cancel the booking now, by the deadline set for the company.
|
||||||
|
/// The times of bookings are local times of the server.
|
||||||
|
async fn customer_cancel_status(booking: &ResSumWithItems, now: NaiveDateTime) -> Result<CancelStatus, ServerFnError> {
|
||||||
|
let pool = get_pool().await?;
|
||||||
|
let cancel_hours: (i32,) = query_as("SELECT cancel_hours FROM company")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
let starts = booking.reservations.iter().map(|item| item.reservation.from).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(cancel_status(&booking.summary.state, booking_start(booking.summary.date, &starts), cancel_hours.0, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link to the page of a booking for e-mails. Without a known public address there is none,
|
||||||
|
/// and the placeholder is replaced by nothing.
|
||||||
|
async fn link_for(uuid: &Uuid) -> String {
|
||||||
|
match get_base_url().await {
|
||||||
|
Ok(base_url) => booking_link(&base_url, &uuid.to_string()),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("No link to the booking in the e-mail: {}", e);
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentence that introduces the link appended to an e-mail whose template has no `#link#`.
|
||||||
|
/// It is in the language of the browser that made the request. Without a link nothing is appended.
|
||||||
|
fn link_intro(link: &str) -> Option<String> {
|
||||||
|
if link.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trl("You can view or cancel your booking here:")())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn items_for_reservation(id: i32, pool: &PgPool) -> Result<Vec<ResWithProperty>, ServerFnError> {
|
async fn items_for_reservation(id: i32, pool: &PgPool) -> Result<Vec<ResWithProperty>, ServerFnError> {
|
||||||
Ok(query_as::<_, ResWithProperty>(
|
Ok(query_as::<_, ResWithProperty>(
|
||||||
"SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \
|
"SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \
|
||||||
@@ -160,7 +219,8 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
|
|
||||||
async fn set_state(uuid: Uuid, state: ReservationState) -> Result<(), ServerFnError> {
|
async fn set_state(uuid: Uuid, state: ReservationState) -> Result<(), ServerFnError> {
|
||||||
let pool = get_pool().await?;
|
let pool = get_pool().await?;
|
||||||
query("UPDATE reservation_sum SET state = $1 WHERE uuid = $2")
|
// Used by the staff only; the mark of a cancellation by the customer no longer applies
|
||||||
|
query("UPDATE reservation_sum SET state = $1, canceled_by_customer = false WHERE uuid = $2")
|
||||||
.bind(state)
|
.bind(state)
|
||||||
.bind(uuid)
|
.bind(uuid)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -173,8 +233,10 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
let mailing = get_mailing().await?;
|
let mailing = get_mailing().await?;
|
||||||
let msg = get_message(MessageType::NewReservation).await?;
|
let msg = get_message(MessageType::NewReservation).await?;
|
||||||
|
|
||||||
|
let link = link_for(&reservation.summary.uuid).await;
|
||||||
|
|
||||||
for m in emails_for_notify().await? {
|
for m in emails_for_notify().await? {
|
||||||
mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), reservation))?;
|
mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), reservation).with_link(&link, None))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -190,11 +252,15 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
return Err(AppError::MailSendError("No admin mail".to_string()))
|
return Err(AppError::MailSendError("No admin mail".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
mailing.send_mail(MailMessage::new(admin_mail.clone().unwrap(), reservation.customer.email.clone(), msg, &reservation))?;
|
let link = link_for(&uuid).await;
|
||||||
|
mailing.send_mail(MailMessage::new(admin_mail.clone().unwrap(), reservation.customer.email.clone(), msg, &reservation)
|
||||||
|
.with_link(&link, link_intro(&link).as_deref()))?;
|
||||||
notify_new_all(admin_mail.unwrap(), &reservation).await
|
notify_new_all(admin_mail.unwrap(), &reservation).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_notify(uuid: Uuid, msg: Message) -> Result<(), AppError> {
|
/// Sends a message about the booking to its customer. The link to the booking is appended
|
||||||
|
/// to a template without `#link#` only when asked for.
|
||||||
|
async fn send_notify(uuid: Uuid, msg: Message, append_link: bool) -> Result<(), AppError> {
|
||||||
let mailing = get_mailing().await?;
|
let mailing = get_mailing().await?;
|
||||||
let reservation = reservation_by_uuid(uuid).await?;
|
let reservation = reservation_by_uuid(uuid).await?;
|
||||||
let admin_mail = admin_email().await;
|
let admin_mail = admin_email().await;
|
||||||
@@ -203,15 +269,39 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
|||||||
return Err(AppError::MailSendError("No admin mail".to_string()))
|
return Err(AppError::MailSendError("No admin mail".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
mailing.send_mail(MailMessage::new(admin_mail.unwrap(), reservation.customer.email.clone(), msg, &reservation))
|
let link = link_for(&uuid).await;
|
||||||
|
let intro = if append_link { link_intro(&link) } else { None };
|
||||||
|
|
||||||
|
mailing.send_mail(MailMessage::new(admin_mail.unwrap(), reservation.customer.email.clone(), msg, &reservation)
|
||||||
|
.with_link(&link, intro.as_deref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn notify_approve(uuid: Uuid) -> Result<(), AppError> {
|
async fn notify_approve(uuid: Uuid) -> Result<(), AppError> {
|
||||||
send_notify(uuid, get_message(MessageType::ReservationApp).await?).await
|
send_notify(uuid, get_message(MessageType::ReservationApp).await?, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// There is nothing left to cancel, so no link is appended
|
||||||
async fn notify_cancel(uuid: Uuid) -> Result<(), AppError> {
|
async fn notify_cancel(uuid: Uuid) -> Result<(), AppError> {
|
||||||
send_notify(uuid, get_message(MessageType::ReservationCanceled).await?).await
|
send_notify(uuid, get_message(MessageType::ReservationCanceled).await?, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The customer cancelled: a confirmation for them and a message for the staff
|
||||||
|
async fn notify_customer_cancel(uuid: Uuid) -> Result<(), AppError> {
|
||||||
|
notify_cancel(uuid).await?;
|
||||||
|
|
||||||
|
let mailing = get_mailing().await?;
|
||||||
|
let msg = get_message(MessageType::CustomerCanceled).await?;
|
||||||
|
let reservation = reservation_by_uuid(uuid).await?;
|
||||||
|
let Some(admin_mail) = admin_email().await else {
|
||||||
|
return Err(AppError::MailSendError("No admin mail".to_string()))
|
||||||
|
};
|
||||||
|
let link = link_for(&uuid).await;
|
||||||
|
|
||||||
|
for m in emails_for_notify().await? {
|
||||||
|
mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), &reservation).with_link(&link, None))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn month_chart_data(year: i32) -> Result<Vec<ChartData>, AppError> {
|
async fn month_chart_data(year: i32) -> Result<Vec<ChartData>, AppError> {
|
||||||
@@ -448,6 +538,80 @@ pub async fn cancel(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
|
|||||||
Ok(ApiResponse::Data(()))
|
Ok(ApiResponse::Data(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The booking for the page behind the link from e-mails. Whoever knows the uuid may see it;
|
||||||
|
/// an unknown and a malformed uuid give the same answer.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_booking(uuid: String) -> Result<ApiResponse<PublicBooking>, ServerFnError> {
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
let Ok(uuid) = Uuid::parse_str(&uuid) else {
|
||||||
|
return Ok(ApiResponse::Error("Booking not found".to_string()))
|
||||||
|
};
|
||||||
|
let Some(booking) = find_booking(&uuid).await? else {
|
||||||
|
return Ok(ApiResponse::Error("Booking not found".to_string()))
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = customer_cancel_status(&booking, Local::now().naive_local()).await?;
|
||||||
|
let mut items = booking.reservations.iter().map(|item| PublicBookingItem {
|
||||||
|
name: item.property.name.clone(),
|
||||||
|
from: item.reservation.from,
|
||||||
|
to: item.reservation.to,
|
||||||
|
}).collect::<Vec<_>>();
|
||||||
|
items.sort_by(|a, b| (a.from, &a.name).cmp(&(b.from, &b.name)));
|
||||||
|
|
||||||
|
Ok(ApiResponse::Data(PublicBooking {
|
||||||
|
date: booking.summary.date,
|
||||||
|
items,
|
||||||
|
price: booking.summary.price,
|
||||||
|
state: booking.summary.state.clone(),
|
||||||
|
can_cancel: matches!(status, CancelStatus::Allowed(_)),
|
||||||
|
cancel_until: match status {
|
||||||
|
CancelStatus::Allowed(until) | CancelStatus::TooLate(until) => until,
|
||||||
|
CancelStatus::AlreadyCanceled => booking_start(booking.summary.date, &[]),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancelling by the customer, from the page behind the link. Public: the uuid is the secret.
|
||||||
|
/// The booking is kept, in the cancelled state.
|
||||||
|
#[server]
|
||||||
|
pub async fn cancel_booking(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
|
||||||
|
use chrono::Local;
|
||||||
|
use crate::backend::data::ReservationState;
|
||||||
|
|
||||||
|
let Ok(uuid) = Uuid::parse_str(&uuid) else {
|
||||||
|
return Ok(ApiResponse::Error("Booking not found".to_string()))
|
||||||
|
};
|
||||||
|
let Some(booking) = find_booking(&uuid).await? else {
|
||||||
|
return Ok(ApiResponse::Error("Booking not found".to_string()))
|
||||||
|
};
|
||||||
|
|
||||||
|
match customer_cancel_status(&booking, Local::now().naive_local()).await? {
|
||||||
|
// Cancelling twice (a double click, a reloaded page) is not an error
|
||||||
|
CancelStatus::AlreadyCanceled => return Ok(ApiResponse::Data(())),
|
||||||
|
CancelStatus::TooLate(_) => return Ok(ApiResponse::Error("This booking can no longer be cancelled".to_string())),
|
||||||
|
CancelStatus::Allowed(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = get_pool().await?;
|
||||||
|
let updated = query("UPDATE reservation_sum SET state = $1, canceled_by_customer = true WHERE uuid = $2 AND state <> $1")
|
||||||
|
.bind(ReservationState::Canceled)
|
||||||
|
.bind(uuid)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Somebody else may have cancelled it in the meantime; then the e-mails were sent already
|
||||||
|
if updated.rows_affected() > 0 {
|
||||||
|
info!("Booking cancelled by the customer");
|
||||||
|
|
||||||
|
if let Err(e) = notify_customer_cancel(uuid).await {
|
||||||
|
warn!("Cancel notification not send: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ApiResponse::Data(()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
fn chart(data: &Vec<ChartData>, title: &str, month: bool, dark: bool) -> String {
|
fn chart(data: &Vec<ChartData>, title: &str, month: bool, dark: bool) -> String {
|
||||||
use charts_rs::{BarChart, THEME_ANT, THEME_DARK};
|
use charts_rs::{BarChart, THEME_ANT, THEME_DARK};
|
||||||
|
|||||||
+33
-1
@@ -13,7 +13,9 @@ use serde::Deserialize;
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct Network {
|
pub struct Network {
|
||||||
bind_ip: String,
|
bind_ip: String,
|
||||||
port: u16
|
port: u16,
|
||||||
|
/// Address under which the application is reached from outside, for links in e-mails
|
||||||
|
public_url: Option<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
@@ -35,6 +37,18 @@ impl Network {
|
|||||||
self.port
|
self.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The public address without a trailing slash. `Err` with the configured value when it is not
|
||||||
|
/// an `http(s)://` address.
|
||||||
|
pub fn public_url(&self) -> Result<Option<String>, String> {
|
||||||
|
use crate::backend::cancellation::normalize_base_url;
|
||||||
|
|
||||||
|
match &self.public_url {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(url) if url.trim().is_empty() => Ok(None),
|
||||||
|
Some(url) => normalize_base_url(url).map(Some).ok_or(url.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bind_address(&self) -> SocketAddr {
|
pub fn bind_address(&self) -> SocketAddr {
|
||||||
SocketAddr::from_str(format!("{}:{}", &self.bind_ip, self.port).as_str()).expect("Can't parse bind IP address")
|
SocketAddr::from_str(format!("{}:{}", &self.bind_ip, self.port).as_str()).expect("Can't parse bind IP address")
|
||||||
}
|
}
|
||||||
@@ -259,6 +273,24 @@ mod tests {
|
|||||||
assert_eq!(config.mailing().from(), "from@address.cz");
|
assert_eq!(config.mailing().from(), "from@address.cz");
|
||||||
assert_eq!(config.mailing().path(), &Some("Maildir".to_string()));
|
assert_eq!(config.mailing().path(), &Some("Maildir".to_string()));
|
||||||
assert_eq!(config.mailing().server(), &None);
|
assert_eq!(config.mailing().server(), &None);
|
||||||
|
// Commented out in the sample
|
||||||
|
assert_eq!(config.network().public_url(), Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn network(public_url: &str) -> Network {
|
||||||
|
toml::from_str(&format!("bind_ip = '127.0.0.1'\nport = 3000\npublic_url = '{}'", public_url)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_url_is_normalized() {
|
||||||
|
assert_eq!(network("https://booking.example.com/").public_url(), Ok(Some("https://booking.example.com".to_string())));
|
||||||
|
assert_eq!(network(" ").public_url(), Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_public_url_is_reported() {
|
||||||
|
assert_eq!(network("booking.example.com").public_url(), Err("booking.example.com".to_string()));
|
||||||
|
assert_eq!(network("https://example.com/?a=b").public_url(), Err("https://example.com/?a=b".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates `<temp>/<name>/site/data` and returns the `<temp>/<name>` directory
|
/// Creates `<temp>/<name>/site/data` and returns the `<temp>/<name>` directory
|
||||||
|
|||||||
@@ -165,6 +165,24 @@ lazy_static! {
|
|||||||
("Save", "Uložit"),
|
("Save", "Uložit"),
|
||||||
("Reset to default", "Obnovit výchozí"),
|
("Reset to default", "Obnovit výchozí"),
|
||||||
("Settings saved", "Nastavení je uloženo"),
|
("Settings saved", "Nastavení je uloženo"),
|
||||||
|
("Your booking", "Vaše rezervace"),
|
||||||
|
("Booked", "Rezervováno"),
|
||||||
|
("Waiting for approval", "Čeká na potvrzení"),
|
||||||
|
("Booking not found", "Rezervace nebyla nalezena"),
|
||||||
|
("You can cancel this booking until", "Rezervaci můžete zrušit do"),
|
||||||
|
("Do you really want to cancel this booking?", "Opravdu chcete rezervaci zrušit?"),
|
||||||
|
("Yes, cancel booking", "Ano, zrušit rezervaci"),
|
||||||
|
("This booking is cancelled.", "Tato rezervace je zrušená."),
|
||||||
|
("This booking can no longer be cancelled, it was possible until", "Tuto rezervaci už zrušit nelze, bylo to možné do"),
|
||||||
|
("This booking can no longer be cancelled", "Tuto rezervaci už zrušit nelze"),
|
||||||
|
("You can view or cancel your booking here:", "Rezervaci si můžete zobrazit nebo zrušit zde:"),
|
||||||
|
("Canceled by customer", "Zrušená zákazníkem"),
|
||||||
|
("Booking canceled by customer - for staff", "Rezervace zrušená zákazníkem - pro obsluhu"),
|
||||||
|
("Customers can cancel a booking until (hours before its start)", "Zákazník může rezervaci zrušit nejpozději (hodin před začátkem)"),
|
||||||
|
("0 means until the start of the booking.", "0 znamená až do začátku rezervace."),
|
||||||
|
("Cancellation deadline must be between 0 and 720 hours", "Lhůta pro zrušení musí být 0 až 720 hodin"),
|
||||||
|
("Invalid company data", "Neplatné údaje organizace"),
|
||||||
|
("In the texts, #date# is replaced by the date of the booking, #summary# by the booked items and #link# by the link to the page where the customer can view and cancel the booking. E-mails for the customer about a new and an approved booking get the link at the end when their text does not contain #link#.", "V textech se #date# nahradí datem rezervace, #summary# rezervovanými položkami a #link# odkazem na stránku, kde si zákazník může rezervaci zobrazit a zrušit. E-maily pro zákazníka o nové a o potvrzené rezervaci dostanou odkaz na konec, pokud jejich text #link# neobsahuje."),
|
||||||
("Text under the banner", "Text pod bannerem"),
|
("Text under the banner", "Text pod bannerem"),
|
||||||
("Bold", "Tučně"),
|
("Bold", "Tučně"),
|
||||||
("Italic", "Kurzíva"),
|
("Italic", "Kurzíva"),
|
||||||
|
|||||||
+14
-2
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use actix_web::middleware::Logger;
|
use actix_web::middleware::Logger;
|
||||||
use leptos_captcha::spow::pow::Pow;
|
use leptos_captcha::spow::pow::Pow;
|
||||||
use log::error;
|
use log::{error, warn};
|
||||||
use rezervator::backend::appearance::{check_appearance, ensure_data_dir, is_broken_upload};
|
use rezervator::backend::appearance::{check_appearance, ensure_data_dir, is_broken_upload};
|
||||||
use rezervator::backend::company::check_company;
|
use rezervator::backend::company::check_company;
|
||||||
use rezervator::backend::mail::{check_messages, Mailer};
|
use rezervator::backend::mail::{check_messages, Mailer};
|
||||||
@@ -106,6 +106,18 @@ async fn main() -> std::io::Result<()> {
|
|||||||
error!("Error while checking messages: {:?}", e);
|
error!("Error while checking messages: {:?}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let public_url = match srv_conf.network().public_url() {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(invalid) => {
|
||||||
|
error!("public_url in the [network] section of the config is not an http(s) address: {}", invalid);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if public_url.is_none() {
|
||||||
|
warn!("public_url is not set in the [network] section of the config. Links in e-mails use the address \
|
||||||
|
of the request instead; set it, unless a reverse proxy passes only requests for your domain.");
|
||||||
|
}
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
// Generate the list of routes in your Leptos App
|
// Generate the list of routes in your Leptos App
|
||||||
let routes = generate_route_list(App);
|
let routes = generate_route_list(App);
|
||||||
@@ -113,7 +125,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
let site_root = leptos_options.site_root.clone().to_string();
|
let site_root = leptos_options.site_root.clone().to_string();
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(Data::new(AppData::new(pool.clone(), mailer.clone(), data_dir.clone())))
|
.app_data(Data::new(AppData::new(pool.clone(), mailer.clone(), data_dir.clone(), public_url.clone())))
|
||||||
// The health check is called every few seconds, keep it out of the access log
|
// The health check is called every few seconds, keep it out of the access log
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
use actix_web::dev::Service;
|
use actix_web::dev::Service;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use chrono::{Datelike, Local};
|
use chrono::{Datelike, Local};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use crate::backend::data::{ApiResponse, ReservationState, ResSumWithItems};
|
use crate::backend::data::{ApiResponse, ReservationState, ReservationSum, ResSumWithItems};
|
||||||
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
|
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
|
||||||
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
|
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
|
||||||
use crate::components::theme::Theme;
|
use crate::components::theme::Theme;
|
||||||
@@ -8,6 +8,16 @@ use crate::components::user_menu::MenuOpener;
|
|||||||
use crate::locales::{loc_date, show_day, trl};
|
use crate::locales::{loc_date, show_day, trl};
|
||||||
use crate::pages::new_reservations::CancelDialog;
|
use crate::pages::new_reservations::CancelDialog;
|
||||||
|
|
||||||
|
/// Translation key of the state of a booking
|
||||||
|
fn state_key(summary: &ReservationSum) -> &'static str {
|
||||||
|
match summary.state {
|
||||||
|
ReservationState::New => "New",
|
||||||
|
ReservationState::Approved => "Approved",
|
||||||
|
ReservationState::Canceled if summary.canceled_by_customer => "Canceled by customer",
|
||||||
|
ReservationState::Canceled => "Canceled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
|
fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
|
||||||
let menu = MenuOpener::new();
|
let menu = MenuOpener::new();
|
||||||
@@ -187,7 +197,7 @@ pub fn bookings() -> impl IntoView {
|
|||||||
<td>{loc_date(data.summary.date)}</td>
|
<td>{loc_date(data.summary.date)}</td>
|
||||||
<td>{data.customer.full_name.clone()}</td>
|
<td>{data.customer.full_name.clone()}</td>
|
||||||
<td>{data.summary.price.to_string()}</td>
|
<td>{data.summary.price.to_string()}</td>
|
||||||
<td>{trl(&data.summary.state.to_string())}</td>
|
<td>{trl(state_key(&data.summary))}</td>
|
||||||
<td><button type="button" class="btn"
|
<td><button type="button" class="btn"
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
res_detail.set(data.clone());
|
res_detail.set(data.clone());
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use crate::backend::data::{ApiResponse, PublicBooking, ReservationState};
|
||||||
|
use crate::backend::reservation::{get_booking, CancelBooking};
|
||||||
|
use crate::locales::{loc_date, trl};
|
||||||
|
use crate::pages::public::PublicShell;
|
||||||
|
|
||||||
|
/// The route of the link sent in e-mails: the booking and the possibility to cancel it
|
||||||
|
#[component]
|
||||||
|
pub fn BookingPage() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<PublicShell>
|
||||||
|
<Booking/>
|
||||||
|
</PublicShell>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn booking_detail(booking: PublicBooking, uuid: String, cancel: ServerAction<CancelBooking>) -> impl IntoView {
|
||||||
|
// Cancelling is asked twice, a wrong tap must not cancel a booking
|
||||||
|
let confirming = RwSignal::new(false);
|
||||||
|
let uuid = StoredValue::new(uuid);
|
||||||
|
let canceled = booking.state == ReservationState::Canceled;
|
||||||
|
let deadline = format!("{} {}", loc_date(booking.cancel_until.date())(), booking.cancel_until.time().format("%H:%M"));
|
||||||
|
let state_class = match booking.state {
|
||||||
|
ReservationState::New => "badge bg-label-warning",
|
||||||
|
ReservationState::Approved => "badge bg-label-success",
|
||||||
|
ReservationState::Canceled => "badge bg-label-secondary",
|
||||||
|
};
|
||||||
|
let state_text = match booking.state {
|
||||||
|
ReservationState::New => "Waiting for approval",
|
||||||
|
ReservationState::Approved => "Approved",
|
||||||
|
ReservationState::Canceled => "Canceled",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="mb-3"><span class=state_class>{trl(state_text)}</span></div>
|
||||||
|
<div class="form-label">{trl("Date")}</div>
|
||||||
|
<div class="mb-3">{loc_date(booking.date)}</div>
|
||||||
|
<div class="form-label">{trl("Booked")}</div>
|
||||||
|
<ul class="list-unstyled mb-3">
|
||||||
|
{booking.items.iter().map(|item| view! {
|
||||||
|
<li>{item.name.clone()}": "{item.from.format("%H:%M").to_string()}" – "{item.to.format("%H:%M").to_string()}</li>
|
||||||
|
}).collect_view()}
|
||||||
|
</ul>
|
||||||
|
<div class="form-label">{trl("Total price of booking")}</div>
|
||||||
|
<div class="mb-4">{format!("{} Kč", booking.price)}</div>
|
||||||
|
|
||||||
|
{if booking.can_cancel {
|
||||||
|
view! {
|
||||||
|
<p>{trl("You can cancel this booking until")}" "{deadline}"."</p>
|
||||||
|
<Show
|
||||||
|
when=move || confirming.get()
|
||||||
|
fallback=move || view! {
|
||||||
|
<button type="button" class="btn btn-outline-secondary" on:click=move |_| confirming.set(true)>
|
||||||
|
{trl("Cancel booking")}
|
||||||
|
</button>
|
||||||
|
}>
|
||||||
|
<p><b>{trl("Do you really want to cancel this booking?")}</b></p>
|
||||||
|
<button type="button" class="btn btn-primary me-2" disabled=move || cancel.pending().get()
|
||||||
|
on:click=move |_| {
|
||||||
|
cancel.dispatch(CancelBooking { uuid: uuid.get_value() });
|
||||||
|
}>
|
||||||
|
{trl("Yes, cancel booking")}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" on:click=move |_| confirming.set(false)>
|
||||||
|
{trl("No")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
}.into_any()
|
||||||
|
} else if canceled {
|
||||||
|
view! {
|
||||||
|
<p>{trl("This booking is cancelled.")}</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<p>{trl("This booking can no longer be cancelled, it was possible until")}" "{deadline}"."</p>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn booking() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let uuid = move || params.read().get("uuid").unwrap_or_default();
|
||||||
|
let cancel = ServerAction::<CancelBooking>::new();
|
||||||
|
// Loaded again after an attempt to cancel, whatever its result
|
||||||
|
let booking = Resource::new_blocking(move || (uuid(), cancel.version().get()), |(uuid, _)| get_booking(uuid));
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="card-body public-form">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="bx bx-calendar-check"></i>" "{trl("Your booking")}</h5>
|
||||||
|
{move || match cancel.value().get() {
|
||||||
|
Some(Ok(ApiResponse::Error(e))) => view! {
|
||||||
|
<div class="alert alert-danger">{trl(&e)}</div>
|
||||||
|
}.into_any(),
|
||||||
|
Some(Err(e)) => view! {
|
||||||
|
<div class="alert alert-danger">{trl("Something went wrong")}": "{e.to_string()}</div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! {<span></span>}.into_any()
|
||||||
|
}}
|
||||||
|
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
||||||
|
{move || {
|
||||||
|
booking.get().map(|b| match b {
|
||||||
|
Ok(ApiResponse::Data(b)) => view! {
|
||||||
|
<BookingDetail booking=b uuid=uuid() cancel=cancel/>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(ApiResponse::Error(e)) => view! {
|
||||||
|
<p>{trl(&e)}</p>
|
||||||
|
}.into_any(),
|
||||||
|
Err(e) => view! {
|
||||||
|
<div><p>{trl("Error loading data")}</p><p>{e.to_string()}</p></div>
|
||||||
|
}.into_any()
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Transition>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/">{trl("New booking")}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::backend::cancellation::MAX_CANCEL_HOURS;
|
||||||
use crate::backend::data::Company;
|
use crate::backend::data::Company;
|
||||||
use crate::backend::company::UpdateCompany;
|
use crate::backend::company::UpdateCompany;
|
||||||
use crate::components::modal_box::DialogOpener;
|
use crate::components::modal_box::DialogOpener;
|
||||||
@@ -76,6 +77,22 @@ pub fn CompanyEdit(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col mb-3">
|
||||||
|
<label for="cancelHours" class="form-label">{trl("Customers can cancel a booking until (hours before its start)")}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="cancelHours"
|
||||||
|
class="form-control"
|
||||||
|
min="0"
|
||||||
|
max=MAX_CANCEL_HOURS.to_string()
|
||||||
|
step="1"
|
||||||
|
prop:value={move || company.get().cancel_hours.to_string()}
|
||||||
|
name="company[cancel_hours]"
|
||||||
|
/>
|
||||||
|
<div class="form-text">{trl("0 means until the start of the booking.")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DataForm>
|
</DataForm>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ pub fn CompanyInfo() -> impl IntoView {
|
|||||||
<p><b>{d.name}</b></p>
|
<p><b>{d.name}</b></p>
|
||||||
<p>{d.street}" "{d.house_number}<br/>
|
<p>{d.street}" "{d.house_number}<br/>
|
||||||
{d.zip_code}" "{d.city}
|
{d.zip_code}" "{d.city}
|
||||||
</p></div>}.into_any()
|
</p>
|
||||||
|
<p>{trl("Customers can cancel a booking until (hours before its start)")}": "{d.cancel_hours}</p>
|
||||||
|
</div>}.into_any()
|
||||||
}
|
}
|
||||||
ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}.into_any()}
|
ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}.into_any()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::pages::mail_view::MailView;
|
|||||||
pub fn mail_settings() -> impl IntoView {
|
pub fn mail_settings() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<h1>{trl("Mail settings")}</h1>
|
<h1>{trl("Mail settings")}</h1>
|
||||||
|
<p>{trl("In the texts, #date# is replaced by the date of the booking, #summary# by the booked items and #link# by the link to the page where the customer can view and cancel the booking. E-mails for the customer about a new and an approved booking get the link at the end when their text does not contain #link#.")}</p>
|
||||||
<div class="row mb-5">
|
<div class="row mb-5">
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<MailView title="New booking" mail_type=MessageType::NewReservation/>
|
<MailView title="New booking" mail_type=MessageType::NewReservation/>
|
||||||
@@ -23,5 +24,11 @@ pub fn mail_settings() -> impl IntoView {
|
|||||||
<MailView title="Booking canceled" mail_type=MessageType::ReservationCanceled/>
|
<MailView title="Booking canceled" mail_type=MessageType::ReservationCanceled/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-md">
|
||||||
|
<MailView title="Booking canceled by customer - for staff" mail_type=MessageType::CustomerCanceled/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md"></div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ mod company_info;
|
|||||||
mod company_edit;
|
mod company_edit;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod public;
|
pub mod public;
|
||||||
|
pub mod booking;
|
||||||
pub mod profile_edit;
|
pub mod profile_edit;
|
||||||
pub mod change_pwd;
|
pub mod change_pwd;
|
||||||
mod users;
|
mod users;
|
||||||
|
|||||||
+31
-37
@@ -109,22 +109,37 @@ fn time_selector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The public route: the booking form in the colours the customer has set.
|
/// Wrapper of the public pages: the colours and layout the customer has set, and the banner.
|
||||||
/// Styles in `assets/css/public.css` are scoped by the wrapper, so the form embedded
|
/// Styles in `assets/css/public.css` are scoped by it, so the form embedded in the admin portal
|
||||||
/// in the admin portal is not affected.
|
/// is not affected.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PublicPage() -> impl IntoView {
|
pub fn PublicShell(
|
||||||
|
/// Show the text under the banner
|
||||||
|
#[prop(optional)] with_text: bool,
|
||||||
|
children: ChildrenFn) -> impl IntoView {
|
||||||
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
|
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
||||||
{move || {
|
{move || {
|
||||||
appearance.get().map(|a| {
|
appearance.get().map(|a| {
|
||||||
// Default look when the settings can't be loaded, the form reports the error itself
|
// Default look when the settings can't be loaded, the page reports its own errors
|
||||||
let a = a.unwrap_or_default();
|
let a = a.unwrap_or_default();
|
||||||
|
let title = a.title.clone().unwrap_or_default();
|
||||||
view! {
|
view! {
|
||||||
<div class=format!("public-page {}", a.layout.classes()) style=a.colors.style()>
|
<div class=format!("public-page {}", a.layout.classes()) style=a.colors.style()>
|
||||||
<Public/>
|
{a.has_banner().then(|| view! {
|
||||||
|
<div class="public-banner" style=a.banner_style()>
|
||||||
|
{a.logo_url().map(|url| view! {
|
||||||
|
<img class="public-banner-logo" src=url alt=""/>
|
||||||
|
})}
|
||||||
|
<h1 class="public-banner-title">{title}</h1>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
{with_text.then(|| view! {
|
||||||
|
<div class="public-banner-text" inner_html=a.text.clone().unwrap_or_default()></div>
|
||||||
|
})}
|
||||||
|
{children()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -133,6 +148,16 @@ pub fn PublicPage() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The public route: the booking form
|
||||||
|
#[component]
|
||||||
|
pub fn PublicPage() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<PublicShell with_text=true>
|
||||||
|
<Public/>
|
||||||
|
</PublicShell>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Public() -> impl IntoView {
|
pub fn Public() -> impl IntoView {
|
||||||
let day = RwSignal::new(NaiveDate::default());
|
let day = RwSignal::new(NaiveDate::default());
|
||||||
@@ -147,7 +172,6 @@ pub fn Public() -> impl IntoView {
|
|||||||
let is_pending = RwSignal::new(None);
|
let is_pending = RwSignal::new(None);
|
||||||
let active_str = RwSignal::new("true".to_string());
|
let active_str = RwSignal::new("true".to_string());
|
||||||
let get_customer = Resource::new_blocking(||(), move |_| get_remembered());
|
let get_customer = Resource::new_blocking(||(), move |_| get_remembered());
|
||||||
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
|
|
||||||
let customer = RwSignal::new(Customer::default());
|
let customer = RwSignal::new(Customer::default());
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
@@ -163,36 +187,6 @@ pub fn Public() -> impl IntoView {
|
|||||||
slots=slots.write_only()
|
slots=slots.write_only()
|
||||||
captcha_state=is_pending.read_only()/>
|
captcha_state=is_pending.read_only()/>
|
||||||
|
|
||||||
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
|
|
||||||
{move || {
|
|
||||||
appearance.get().map(|a| match a {
|
|
||||||
Ok(a) => {
|
|
||||||
let title = a.title.clone().unwrap_or_default();
|
|
||||||
view! {
|
|
||||||
<div>
|
|
||||||
{a.has_banner().then(|| view! {
|
|
||||||
<div class="public-banner" style=a.banner_style()>
|
|
||||||
{a.logo_url().map(|url| view! {
|
|
||||||
<img class="public-banner-logo" src=url alt=""/>
|
|
||||||
})}
|
|
||||||
<h1 class="public-banner-title">{title}</h1>
|
|
||||||
</div>
|
|
||||||
})}
|
|
||||||
<div>
|
|
||||||
<div class="public-banner-text" inner_html={a.text.unwrap_or("".to_string())}>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
},
|
|
||||||
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
|
|
||||||
<p>{e.to_string()}</p></div>
|
|
||||||
}.into_any()}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div class="card-body public-form">
|
<div class="card-body public-form">
|
||||||
<ActionForm
|
<ActionForm
|
||||||
// Capture phase, so the form is validated and the captcha solved before it is dispatched
|
// Capture phase, so the form is validated and the captcha solved before it is dispatched
|
||||||
|
|||||||
Reference in New Issue
Block a user