Booking confirmation email + customer self-service cancel/reschedule (#18)
Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py) on booking creation, with a manage-booking link embedding the ticket-2 signed token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses the existing slot-picker against booking_api's create/cancel/reschedule API, distinguishing an invalid/expired link from an already-cancelled one. Sender address uses the client's own domain when configured, falling back to a mivanchenko.de address otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,13 @@ DB_PASSWORD=change-me-strong
|
||||
CRM_API_TOKEN=change-me-long-random
|
||||
BOOKING_TOKEN_SECRET=change-me-long-random-too
|
||||
SHEET_ID=1raMSWRZw_JfHlWqOb3LbhaQ6LWx0VGblxIV4Z2pSzp8
|
||||
# Booking confirmation email (#18). Left blank, sending is skipped (logged,
|
||||
# not fatal) -- mail relay setup is a separate infra/triage item.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
# Used as the From address when a client has no domain configured.
|
||||
MAIL_FALLBACK_FROM=noreply@mivanchenko.de
|
||||
# Base URL the manage-booking link in the confirmation email is built from.
|
||||
PUBLIC_BASE_URL=https://onboard.mivanchenko.de
|
||||
|
||||
@@ -21,10 +21,12 @@ import db
|
||||
from sheets import Sheets
|
||||
from booking_api import bp as booking_bp
|
||||
from public_booking import bp as public_booking_bp
|
||||
from manage_booking import bp as manage_booking_bp
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||
app.register_blueprint(booking_bp)
|
||||
app.register_blueprint(public_booking_bp)
|
||||
app.register_blueprint(manage_booking_bp)
|
||||
|
||||
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
|
||||
# Separate read-only token for the public iCal feed (calendar apps can't send
|
||||
|
||||
@@ -14,6 +14,7 @@ from flask import Blueprint, jsonify, request
|
||||
|
||||
import availability
|
||||
import booking_db as bdb
|
||||
import booking_mail
|
||||
|
||||
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
|
||||
|
||||
@@ -32,9 +33,10 @@ def _mint_manage_token(client_id, booking_id):
|
||||
return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def _verify_manage_token(token):
|
||||
def verify_manage_token(token):
|
||||
"""Returns (client_id, booking_id), or None if the token is
|
||||
missing/expired/malformed."""
|
||||
missing/expired/malformed. Public: manage_booking.py (#18) also verifies
|
||||
tokens to decide what to render, without itself owning the token format."""
|
||||
try:
|
||||
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
|
||||
except jwt.PyJWTError:
|
||||
@@ -94,23 +96,54 @@ def _available_slots(client_id, resource, tz_name, duration_minutes, date_from,
|
||||
busy=busy)
|
||||
|
||||
|
||||
class _BadDuration(ValueError):
|
||||
"""Raised by _resolve_duration_minutes on an unknown service_id or a
|
||||
non-integer duration_minutes -- turned into a clean 4xx by slots()."""
|
||||
|
||||
|
||||
def _resolve_duration_minutes(client_id, service_id, duration_param):
|
||||
"""service_id is the normal (public-page) path; duration_minutes is an
|
||||
alternative for it: bookings store the service's *name*, not its id
|
||||
(#15's schema), so the manage-booking page (#18) -- which only has the
|
||||
existing booking's duration, not a service_id -- browses reschedule slots
|
||||
by duration directly."""
|
||||
if service_id:
|
||||
service = bdb.get_service(client_id, service_id)
|
||||
if service is None:
|
||||
raise _BadDuration("not found")
|
||||
return service["duration_minutes"]
|
||||
try:
|
||||
return int(duration_param)
|
||||
except (TypeError, ValueError):
|
||||
raise _BadDuration("duration_minutes must be an integer") from None
|
||||
|
||||
|
||||
@bp.get("/slots")
|
||||
def slots():
|
||||
client_id = request.args.get("client_id")
|
||||
resource_id = request.args.get("resource_id")
|
||||
service_id = request.args.get("service_id")
|
||||
duration_param = request.args.get("duration_minutes")
|
||||
date_from = _parse_date(request.args.get("date_from"))
|
||||
date_to = _parse_date(request.args.get("date_to"))
|
||||
if not (client_id and resource_id and service_id and date_from and date_to):
|
||||
return jsonify({"error": "client_id, resource_id, service_id, date_from, "
|
||||
"date_to are required"}), 400
|
||||
exclude_booking_id = request.args.get("exclude_booking_id")
|
||||
if not (client_id and resource_id and (service_id or duration_param)
|
||||
and date_from and date_to):
|
||||
return jsonify({"error": "client_id, resource_id, date_from, date_to and "
|
||||
"either service_id or duration_minutes are "
|
||||
"required"}), 400
|
||||
resource = bdb.get_resource(client_id, resource_id)
|
||||
service = bdb.get_service(client_id, service_id)
|
||||
client = bdb.get_client(client_id)
|
||||
if resource is None or service is None or client is None:
|
||||
if resource is None or client is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
try:
|
||||
duration_minutes = _resolve_duration_minutes(client_id, service_id, duration_param)
|
||||
except _BadDuration as e:
|
||||
status = 404 if str(e) == "not found" else 400
|
||||
return jsonify({"error": str(e)}), status
|
||||
slot_list = _available_slots(client_id, resource, _tz_name(client),
|
||||
service["duration_minutes"], date_from, date_to)
|
||||
duration_minutes, date_from, date_to,
|
||||
exclude_booking_id=exclude_booking_id)
|
||||
return jsonify({"slots": [s.isoformat() for s in slot_list]})
|
||||
|
||||
|
||||
@@ -161,6 +194,11 @@ def create_booking():
|
||||
return jsonify({"error": "that slot was just taken"}), 409
|
||||
|
||||
token = _mint_manage_token(client_id, booking["booking_id"])
|
||||
# #18: fires for every caller of this endpoint, public page (#17) included.
|
||||
# A future ticket-6 owner-manual-entry flow only gets the confirmation
|
||||
# email for free if it also creates bookings through this endpoint --
|
||||
# calling bdb.create_booking() directly would bypass it.
|
||||
booking_mail.send_booking_confirmation(client, booking, token)
|
||||
return jsonify({"booking_id": booking["booking_id"], "status": booking["status"],
|
||||
"token": token}), 201
|
||||
|
||||
@@ -168,7 +206,7 @@ def create_booking():
|
||||
@bp.post("/cancel")
|
||||
def cancel_booking():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
resolved = _verify_manage_token(body.get("token"))
|
||||
resolved = verify_manage_token(body.get("token"))
|
||||
if resolved is None:
|
||||
return jsonify({"error": "invalid or expired token"}), 400
|
||||
client_id, booking_id = resolved
|
||||
@@ -184,7 +222,7 @@ def cancel_booking():
|
||||
@bp.post("/reschedule")
|
||||
def reschedule_booking():
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
resolved = _verify_manage_token(body.get("token"))
|
||||
resolved = verify_manage_token(body.get("token"))
|
||||
if resolved is None:
|
||||
return jsonify({"error": "invalid or expired token"}), 400
|
||||
client_id, booking_id = resolved
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Booking confirmation email (#18): sender-address resolution, the
|
||||
customer-facing manage-booking link, and the render/send call. Booking
|
||||
creation must succeed even if this fails -- see mailer.py's fire-and-forget
|
||||
send, which this relies on rather than talking to smtplib directly.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import render_template
|
||||
|
||||
import mailer
|
||||
|
||||
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://onboard.mivanchenko.de").rstrip("/")
|
||||
|
||||
# customer_contact is a single free-text "E-Mail oder Telefon" field (#16/#17)
|
||||
# -- not guaranteed to be an email address. Only attempt to send when it
|
||||
# looks like one.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def looks_like_email(contact):
|
||||
return bool(_EMAIL_RE.match((contact or "").strip()))
|
||||
|
||||
|
||||
def manage_url(token):
|
||||
return f"{PUBLIC_BASE_URL}/manage/{token}"
|
||||
|
||||
|
||||
# clients.domain is free text from the onboarding form (e.g. "cafe-lichtblick.de",
|
||||
# but nothing stops "https://cafe-lichtblick.de/" being entered) -- strip any
|
||||
# scheme/path/whitespace so a malformed value can't end up in a From header.
|
||||
_DOMAIN_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*://)?([^/\s]+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _clean_domain(domain):
|
||||
m = _DOMAIN_RE.match((domain or "").strip())
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _sender_for(client):
|
||||
domain = _clean_domain((client or {}).get("domain"))
|
||||
return f"noreply@{domain}" if domain else mailer.MAIL_FALLBACK_FROM
|
||||
|
||||
|
||||
def send_booking_confirmation(client, booking, token):
|
||||
"""No-op if customer_contact doesn't look like an email address -- it's a
|
||||
free-text "E-Mail oder Telefon" field (#16/#17), so this is expected for
|
||||
phone-only customers, not an error."""
|
||||
if not looks_like_email(booking.get("customer_contact")):
|
||||
print(f"[booking_mail] customer_contact for booking "
|
||||
f"{booking.get('booking_id')} doesn't look like an email, "
|
||||
f"skipping confirmation send", flush=True)
|
||||
return
|
||||
tz = ZoneInfo((client or {}).get("timezone") or "Europe/Berlin")
|
||||
business_name = (client or {}).get("business_name") or "Ihr Termin"
|
||||
html = render_template(
|
||||
"emails/booking_confirmation.html",
|
||||
business_name=business_name,
|
||||
customer_name=booking["customer_name"],
|
||||
service=booking["service"],
|
||||
start_local=booking["start_time"].astimezone(tz),
|
||||
status=booking["status"],
|
||||
manage_url=manage_url(token),
|
||||
)
|
||||
mailer.send_email(
|
||||
booking["customer_contact"],
|
||||
f"Terminbestätigung – {business_name}",
|
||||
html,
|
||||
from_addr=_sender_for(client),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Best-effort SMTP email sending (#18).
|
||||
|
||||
Mirrors app.py's DB -> Sheets mirror pattern: a single background worker
|
||||
thread drains a queue, and a send failure is logged, never raised back to the
|
||||
caller -- booking creation must succeed even if the mail relay is down.
|
||||
"""
|
||||
import os
|
||||
import queue
|
||||
import smtplib
|
||||
import threading
|
||||
import traceback
|
||||
from email.message import EmailMessage
|
||||
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||
# Used when a client has no domain configured -- see booking_mail.py.
|
||||
MAIL_FALLBACK_FROM = os.environ.get("MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||
|
||||
_queue = queue.Queue()
|
||||
|
||||
|
||||
def _send_now(msg):
|
||||
if not SMTP_HOST:
|
||||
print(f"[mailer] SMTP_HOST not configured, skipping send to {msg['To']}", flush=True)
|
||||
return
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as smtp:
|
||||
smtp.starttls()
|
||||
if SMTP_USERNAME:
|
||||
smtp.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
def _worker():
|
||||
while True:
|
||||
msg = _queue.get()
|
||||
try:
|
||||
_send_now(msg)
|
||||
except Exception: # noqa: BLE001
|
||||
print(f"[mailer] send to {msg['To']} failed:", flush=True)
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
|
||||
def send_email(to_addr, subject, html_body, from_addr=None):
|
||||
"""Queue an HTML email for best-effort async delivery. Never raises and
|
||||
never blocks the caller on the network -- the actual SMTP conversation
|
||||
happens on the background worker thread."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_addr or MAIL_FALLBACK_FROM
|
||||
msg["To"] = to_addr
|
||||
msg.set_content(html_body, subtype="html")
|
||||
_queue.put(msg)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Customer self-service manage-booking page (#18), reached via the
|
||||
token-linked link sent in the confirmation email (booking_mail.py). The
|
||||
signed token (booking_api.verify_manage_token) is the only source of
|
||||
identity here -- customers have no account, so this route needs no login.
|
||||
|
||||
The actual cancel/reschedule mutations still go through booking_api.py's
|
||||
JSON API (#16); this route only resolves the token, decides which of the
|
||||
three states (invalid/expired, already used, active) to render, and lets the
|
||||
page's own JS drive the API from there.
|
||||
"""
|
||||
from flask import Blueprint, render_template
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import booking_db as bdb
|
||||
from booking_api import verify_manage_token
|
||||
|
||||
bp = Blueprint("manage_booking", __name__)
|
||||
|
||||
|
||||
@bp.get("/manage/<token>")
|
||||
def manage_page(token):
|
||||
resolved = verify_manage_token(token)
|
||||
if resolved is None:
|
||||
# Distinct from "already used" per #18's acceptance criteria -- an
|
||||
# expired/malformed token never resolved to a booking at all.
|
||||
return render_template("manage.html", state="invalid"), 400
|
||||
|
||||
client_id, booking_id = resolved
|
||||
booking = bdb.get_booking(client_id, booking_id)
|
||||
client = bdb.get_client(client_id)
|
||||
if booking is None or client is None:
|
||||
return render_template("manage.html", state="invalid"), 404
|
||||
|
||||
if booking["status"] == "cancelled":
|
||||
return render_template("manage.html", state="used")
|
||||
|
||||
tz = ZoneInfo(client.get("timezone") or "Europe/Berlin")
|
||||
duration_minutes = int(
|
||||
(booking["end_time"] - booking["start_time"]).total_seconds() // 60)
|
||||
return render_template(
|
||||
"manage.html",
|
||||
state="active",
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
booking_id=booking_id,
|
||||
resource_id=booking["resource_id"],
|
||||
service=booking["service"],
|
||||
duration_minutes=duration_minutes,
|
||||
start_local=booking["start_time"].astimezone(tz),
|
||||
status=booking["status"],
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Terminbestätigung</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#16302f;">
|
||||
<h1 style="font-size:1.2rem; margin:0 0 14px;">{{ business_name }}</h1>
|
||||
<p>Hallo {{ customer_name }},</p>
|
||||
{% if status == 'pending' %}
|
||||
<p>Ihre Buchung wartet noch auf Bestätigung:</p>
|
||||
{% else %}
|
||||
<p>Ihre Buchung ist bestätigt:</p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
<li>Leistung: {{ service }}</li>
|
||||
<li>Termin: {{ start_local.strftime('%d.%m.%Y, %H:%M') }} Uhr</li>
|
||||
</ul>
|
||||
<p>
|
||||
Über den folgenden Link können Sie Ihren Termin jederzeit einsehen, verschieben oder
|
||||
stornieren:
|
||||
</p>
|
||||
<p><a href="{{ manage_url }}">{{ manage_url }}</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,227 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Termin verwalten</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: #0f8a7e;
|
||||
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
|
||||
--danger: #b3261e; --danger-bg: #fdecea;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--ink); padding: 20px; max-width: 480px; }
|
||||
h1 { font-size: 1.2rem; margin: 0 0 18px; }
|
||||
h2 { font-size: .95rem; margin: 0 0 10px; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: .5px; }
|
||||
section { margin-bottom: 22px; }
|
||||
.options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.opt { border: 1px solid var(--line); border-radius: 9px; padding: 10px 14px;
|
||||
background: #fff; cursor: pointer; font: inherit; font-size: .88rem; }
|
||||
.opt.selected { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
input[type=date] { width: 100%; max-width: 320px; padding: 9px 12px;
|
||||
border: 1px solid var(--line); border-radius: 9px; font: inherit; font-size: .9rem; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
|
||||
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-danger { background: var(--danger); }
|
||||
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
|
||||
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
|
||||
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px;
|
||||
background: #f6faf9; }
|
||||
.muted { color: var(--muted); font-size: .85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if state == "invalid" %}
|
||||
<h1>Termin verwalten</h1>
|
||||
<div class="card">
|
||||
<p>Dieser Link ist ungültig oder abgelaufen.</p>
|
||||
<p class="muted">Bitte wenden Sie sich an das Unternehmen, wenn Sie Ihren Termin ändern
|
||||
möchten.</p>
|
||||
</div>
|
||||
|
||||
{% elif state == "used" %}
|
||||
<h1>Termin verwalten</h1>
|
||||
<div class="card">
|
||||
<p>Diese Buchung wurde bereits storniert.</p>
|
||||
<p class="muted">Dieser Link kann nicht mehr verwendet werden.</p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div id="manage-root">
|
||||
<h1>Termin verwalten</h1>
|
||||
|
||||
<div class="error" id="error-banner"></div>
|
||||
|
||||
<section id="current-section">
|
||||
<h2>Ihr Termin</h2>
|
||||
<p id="current-details">
|
||||
{{ service }} am {{ start_local.strftime('%d.%m.%Y') }} um
|
||||
{{ start_local.strftime('%H:%M') }} Uhr
|
||||
({{ 'wartet noch auf Bestätigung' if status == 'pending' else 'bestätigt' }})
|
||||
</p>
|
||||
<button type="button" class="btn btn-danger" id="cancel-btn">Termin stornieren</button>
|
||||
</section>
|
||||
|
||||
<section id="reschedule-section">
|
||||
<h2>Termin verschieben</h2>
|
||||
<div class="field">
|
||||
<input type="date" id="date-input" />
|
||||
</div>
|
||||
<div class="options" id="slot-options"></div>
|
||||
<button type="button" class="btn" id="reschedule-btn" disabled style="margin-top:12px;">
|
||||
Auf neuen Termin verschieben
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="card" id="confirmation" style="display:none;">
|
||||
<h2>Erledigt</h2>
|
||||
<p id="confirmation-message"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var TOKEN = {{ token|tojson }};
|
||||
var CLIENT_ID = {{ client_id|tojson }};
|
||||
var RESOURCE_ID = {{ resource_id|tojson }};
|
||||
var BOOKING_ID = {{ booking_id|tojson }};
|
||||
var DURATION_MINUTES = {{ duration_minutes|tojson }};
|
||||
|
||||
var selectedSlot = null;
|
||||
|
||||
var errorBanner = document.getElementById('error-banner');
|
||||
var dateInput = document.getElementById('date-input');
|
||||
var slotOptions = document.getElementById('slot-options');
|
||||
var rescheduleBtn = document.getElementById('reschedule-btn');
|
||||
var cancelBtn = document.getElementById('cancel-btn');
|
||||
var confirmation = document.getElementById('confirmation');
|
||||
var confirmationMessage = document.getElementById('confirmation-message');
|
||||
|
||||
var today = new Date();
|
||||
dateInput.value = today.toISOString().slice(0, 10);
|
||||
dateInput.min = today.toISOString().slice(0, 10);
|
||||
|
||||
function showError(msg) {
|
||||
errorBanner.textContent = msg;
|
||||
errorBanner.style.display = msg ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function showDone(msg) {
|
||||
document.getElementById('manage-root').querySelectorAll('section').forEach(function (s) {
|
||||
s.style.display = 'none';
|
||||
});
|
||||
errorBanner.style.display = 'none';
|
||||
confirmationMessage.textContent = msg;
|
||||
confirmation.style.display = 'block';
|
||||
}
|
||||
|
||||
function loadSlots() {
|
||||
slotOptions.innerHTML = '';
|
||||
selectedSlot = null;
|
||||
rescheduleBtn.disabled = true;
|
||||
if (!dateInput.value) {
|
||||
return;
|
||||
}
|
||||
var day = dateInput.value;
|
||||
var params = new URLSearchParams({
|
||||
client_id: CLIENT_ID, resource_id: RESOURCE_ID,
|
||||
duration_minutes: DURATION_MINUTES, exclude_booking_id: BOOKING_ID,
|
||||
date_from: day, date_to: day,
|
||||
});
|
||||
fetch('/api/booking/slots?' + params.toString())
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.slots || data.slots.length === 0) {
|
||||
slotOptions.innerHTML = '<p class="muted">Keine freien Termine an diesem Tag.</p>';
|
||||
return;
|
||||
}
|
||||
data.slots.forEach(function (iso) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'opt';
|
||||
var d = new Date(iso);
|
||||
btn.textContent = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
btn.addEventListener('click', function () {
|
||||
slotOptions.querySelectorAll('.opt').forEach(function (b) {
|
||||
b.classList.remove('selected');
|
||||
});
|
||||
btn.classList.add('selected');
|
||||
selectedSlot = iso;
|
||||
rescheduleBtn.disabled = false;
|
||||
});
|
||||
slotOptions.appendChild(btn);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
||||
});
|
||||
}
|
||||
|
||||
dateInput.addEventListener('change', loadSlots);
|
||||
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
showError('');
|
||||
cancelBtn.disabled = true;
|
||||
fetch('/api/booking/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
showError(res.body.error || 'Die Stornierung ist fehlgeschlagen.');
|
||||
cancelBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
showDone('Ihr Termin wurde storniert.');
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Die Stornierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
cancelBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
rescheduleBtn.addEventListener('click', function () {
|
||||
showError('');
|
||||
rescheduleBtn.disabled = true;
|
||||
fetch('/api/booking/reschedule', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN, start_time: selectedSlot }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
showError('Dieser Termin wurde gerade eben vergeben. Bitte wählen Sie einen anderen.');
|
||||
loadSlots();
|
||||
} else {
|
||||
showError(res.body.error || 'Die Verschiebung ist fehlgeschlagen.');
|
||||
}
|
||||
rescheduleBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
showDone('Ihr Termin wurde verschoben auf '
|
||||
+ new Date(selectedSlot).toLocaleString('de-DE', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}) + '.');
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Die Verschiebung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
rescheduleBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
loadSlots();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -72,6 +72,36 @@ def test_create_booking_auto_confirm_true_yields_confirmed(client):
|
||||
assert "token" in body
|
||||
|
||||
|
||||
def test_create_booking_via_public_endpoint_sends_confirmation_email(client, monkeypatch):
|
||||
"""#18's acceptance criterion: completing a booking via ticket 3's public
|
||||
page (this same POST /api/booking endpoint) triggers the confirmation
|
||||
email, with the manage-booking token embedded in it."""
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
"booking_mail.mailer.send_email",
|
||||
lambda to, subject, html, from_addr=None: sent.append(
|
||||
{"to": to, "subject": subject, "html": html, "from_addr": from_addr}))
|
||||
resource, service = _setup_resource_and_service(
|
||||
auto_confirm=True, min_notice_minutes=0, max_advance_days=365)
|
||||
day = _next_monday(date.today())
|
||||
slots = client.get("/api/booking/slots", query_string={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"],
|
||||
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
|
||||
|
||||
resp = client.post("/api/booking", json={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"], "start_time": slots[0],
|
||||
"customer_name": "Kim", "customer_contact": "kim@example.com"})
|
||||
assert resp.status_code == 201
|
||||
token = resp.get_json()["token"]
|
||||
|
||||
assert len(sent) == 1
|
||||
assert sent[0]["to"] == "kim@example.com"
|
||||
assert f"/manage/{token}" in sent[0]["html"]
|
||||
assert "Haircut" in sent[0]["html"]
|
||||
|
||||
|
||||
def test_create_booking_auto_confirm_false_yields_pending(client):
|
||||
resource, service = _setup_resource_and_service(
|
||||
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
|
||||
@@ -251,12 +281,12 @@ def test_reschedule_to_same_slot_is_a_noop_success(client):
|
||||
|
||||
def test_manage_token_is_scoped_to_its_own_client():
|
||||
resource, service = _setup_resource_and_service(client_id=CLIENT_A)
|
||||
from booking_api import _mint_manage_token, _verify_manage_token
|
||||
from booking_api import _mint_manage_token, verify_manage_token
|
||||
booking = bdb.create_booking(
|
||||
CLIENT_A, resource["resource_id"], "Ivy", "i@example.com", "Haircut",
|
||||
datetime.now(timezone.utc) + timedelta(days=1),
|
||||
datetime.now(timezone.utc) + timedelta(days=1, hours=1))
|
||||
token = _mint_manage_token(CLIENT_A, booking["booking_id"])
|
||||
assert _verify_manage_token(token) == (CLIENT_A, booking["booking_id"])
|
||||
assert verify_manage_token(token) == (CLIENT_A, booking["booking_id"])
|
||||
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
|
||||
assert _verify_manage_token(tampered) is None
|
||||
assert verify_manage_token(tampered) is None
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Unit tests for booking_mail.py (#18): sender-address resolution and the
|
||||
skip-if-not-an-email guard, per the acceptance criterion that customer_contact
|
||||
(free-text "E-Mail oder Telefon") may not actually be an email address.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
import booking_mail
|
||||
from app import app as flask_app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contact,expected", [
|
||||
("alice@example.com", True),
|
||||
("Alice@Example.COM", True),
|
||||
("+49 151 2345678", False),
|
||||
("0151-2345678", False),
|
||||
("not-an-email", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
])
|
||||
def test_looks_like_email(contact, expected):
|
||||
assert booking_mail.looks_like_email(contact) is expected
|
||||
|
||||
|
||||
def test_manage_url_embeds_token(monkeypatch):
|
||||
monkeypatch.setattr(booking_mail, "PUBLIC_BASE_URL", "https://onboard.example.com")
|
||||
assert booking_mail.manage_url("abc.def.ghi") == \
|
||||
"https://onboard.example.com/manage/abc.def.ghi"
|
||||
|
||||
|
||||
def test_sender_uses_client_domain_when_configured():
|
||||
client = {"domain": "happynails.de"}
|
||||
assert booking_mail._sender_for(client) == "noreply@happynails.de"
|
||||
|
||||
|
||||
def test_sender_falls_back_when_client_has_no_domain(monkeypatch):
|
||||
monkeypatch.setattr(booking_mail.mailer, "MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||
assert booking_mail._sender_for({"domain": None}) == "noreply@mivanchenko.de"
|
||||
assert booking_mail._sender_for({}) == "noreply@mivanchenko.de"
|
||||
assert booking_mail._sender_for(None) == "noreply@mivanchenko.de"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("happynails.de", "happynails.de"),
|
||||
("https://happynails.de", "happynails.de"),
|
||||
("https://happynails.de/", "happynails.de"),
|
||||
("http://happynails.de/shop", "happynails.de"),
|
||||
(" happynails.de ", "happynails.de"),
|
||||
("HappyNails.de", "happynails.de"),
|
||||
])
|
||||
def test_sender_sanitizes_domain_entered_with_scheme_or_path(raw, expected):
|
||||
assert booking_mail._sender_for({"domain": raw}) == f"noreply@{expected}"
|
||||
|
||||
|
||||
def _booking(contact="alice@example.com"):
|
||||
start = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
return {
|
||||
"customer_name": "Alice",
|
||||
"customer_contact": contact,
|
||||
"service": "Haircut",
|
||||
"start_time": start,
|
||||
"status": "confirmed",
|
||||
}
|
||||
|
||||
|
||||
def test_send_booking_confirmation_sends_when_contact_is_email(monkeypatch):
|
||||
captured = []
|
||||
monkeypatch.setattr(booking_mail.mailer, "send_email",
|
||||
lambda to, subject, html, from_addr=None:
|
||||
captured.append((to, subject, html, from_addr)))
|
||||
client = {"domain": "happynails.de", "business_name": "Happy Nails",
|
||||
"timezone": "Europe/Berlin"}
|
||||
with flask_app.test_request_context():
|
||||
booking_mail.send_booking_confirmation(client, _booking(), "sometoken")
|
||||
assert len(captured) == 1
|
||||
to, subject, html, from_addr = captured[0]
|
||||
assert to == "alice@example.com"
|
||||
assert from_addr == "noreply@happynails.de"
|
||||
assert "Happy Nails" in subject
|
||||
assert "/manage/sometoken" in html
|
||||
assert "Haircut" in html
|
||||
|
||||
|
||||
def test_send_booking_confirmation_skips_when_contact_is_phone(monkeypatch):
|
||||
captured = []
|
||||
monkeypatch.setattr(booking_mail.mailer, "send_email",
|
||||
lambda *a, **kw: captured.append((a, kw)))
|
||||
client = {"business_name": "Happy Nails", "timezone": "Europe/Berlin"}
|
||||
with flask_app.test_request_context():
|
||||
booking_mail.send_booking_confirmation(client, _booking(contact="0151-2345678"),
|
||||
"sometoken")
|
||||
assert captured == []
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for mailer.py's SMTP call shape (#18). Calls _send_now directly
|
||||
rather than going through the background-thread queue, so assertions are
|
||||
synchronous -- the queue itself is just plumbing, already covered indirectly
|
||||
by app.py's identical Sheets-mirror pattern.
|
||||
"""
|
||||
from email.message import EmailMessage
|
||||
|
||||
import pytest
|
||||
|
||||
import mailer
|
||||
|
||||
|
||||
class _FakeSMTP:
|
||||
sent = []
|
||||
login_calls = []
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
pass
|
||||
|
||||
def login(self, username, password):
|
||||
_FakeSMTP.login_calls.append((username, password))
|
||||
|
||||
def send_message(self, msg):
|
||||
_FakeSMTP.sent.append(msg)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_fake_smtp():
|
||||
_FakeSMTP.sent = []
|
||||
_FakeSMTP.login_calls = []
|
||||
yield
|
||||
|
||||
|
||||
def _msg(to="alice@example.com"):
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Terminbestätigung"
|
||||
msg["From"] = "noreply@example.com"
|
||||
msg["To"] = to
|
||||
msg.set_content("<p>hi</p>", subtype="html")
|
||||
return msg
|
||||
|
||||
|
||||
def test_send_now_skips_when_smtp_host_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(mailer, "SMTP_HOST", "")
|
||||
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
|
||||
mailer._send_now(_msg())
|
||||
assert _FakeSMTP.sent == []
|
||||
|
||||
|
||||
def test_send_now_sends_via_smtp_when_configured(monkeypatch):
|
||||
monkeypatch.setattr(mailer, "SMTP_HOST", "smtp.example.com")
|
||||
monkeypatch.setattr(mailer, "SMTP_PORT", 587)
|
||||
monkeypatch.setattr(mailer, "SMTP_USERNAME", "")
|
||||
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
|
||||
msg = _msg()
|
||||
mailer._send_now(msg)
|
||||
assert _FakeSMTP.sent == [msg]
|
||||
assert _FakeSMTP.login_calls == []
|
||||
|
||||
|
||||
def test_send_now_logs_in_when_username_configured(monkeypatch):
|
||||
monkeypatch.setattr(mailer, "SMTP_HOST", "smtp.example.com")
|
||||
monkeypatch.setattr(mailer, "SMTP_USERNAME", "mailer")
|
||||
monkeypatch.setattr(mailer, "SMTP_PASSWORD", "secret")
|
||||
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
|
||||
mailer._send_now(_msg())
|
||||
assert _FakeSMTP.login_calls == [("mailer", "secret")]
|
||||
|
||||
|
||||
def test_send_email_builds_html_message_and_enqueues(monkeypatch):
|
||||
captured = []
|
||||
monkeypatch.setattr(mailer._queue, "put", lambda m: captured.append(m))
|
||||
mailer.send_email("bob@example.com", "Subject line", "<p>body</p>",
|
||||
from_addr="noreply@custom.example")
|
||||
assert len(captured) == 1
|
||||
msg = captured[0]
|
||||
assert msg["To"] == "bob@example.com"
|
||||
assert msg["Subject"] == "Subject line"
|
||||
assert msg["From"] == "noreply@custom.example"
|
||||
assert msg.get_content_type() == "text/html"
|
||||
|
||||
|
||||
def test_send_email_defaults_from_addr_to_fallback(monkeypatch):
|
||||
captured = []
|
||||
monkeypatch.setattr(mailer._queue, "put", lambda m: captured.append(m))
|
||||
monkeypatch.setattr(mailer, "MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||
mailer.send_email("bob@example.com", "Subject", "<p>body</p>")
|
||||
assert captured[0]["From"] == "noreply@mivanchenko.de"
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Flask test client / real-DB integration tests for the manage-booking page
|
||||
(#18), per #14's testing decision: assert on HTTP response + resulting DB
|
||||
state.
|
||||
"""
|
||||
from datetime import date, time, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
import booking_db as bdb
|
||||
from app import app as flask_app
|
||||
from booking_api import _mint_manage_token
|
||||
|
||||
CLIENT_A = "C-TEST-MANAGE-A"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app.test_client()
|
||||
|
||||
|
||||
def _next_monday(after):
|
||||
d = after + timedelta(days=1)
|
||||
while d.weekday() != 0:
|
||||
d += timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
def _setup_resource_and_service(client_id=CLIENT_A):
|
||||
with bdb.db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO clients (client_id, timezone, auto_confirm) VALUES (%s, %s, %s) "
|
||||
"ON CONFLICT (client_id) DO UPDATE SET timezone = EXCLUDED.timezone, "
|
||||
"auto_confirm = EXCLUDED.auto_confirm",
|
||||
(client_id, "Europe/Berlin", True))
|
||||
conn.commit()
|
||||
resource = bdb.create_resource(client_id, "Chair 1", min_notice_minutes=0,
|
||||
max_advance_days=365)
|
||||
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
|
||||
service = bdb.create_service(client_id, "Haircut", 60, price=25)
|
||||
return resource, service
|
||||
|
||||
|
||||
def _create_booking(client, resource, service, slot):
|
||||
return client.post("/api/booking", json={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"], "start_time": slot,
|
||||
"customer_name": "Dana", "customer_contact": "dana@example.com"}).get_json()
|
||||
|
||||
|
||||
def test_manage_page_shows_invalid_message_for_garbage_token(client):
|
||||
resp = client.get("/manage/not-a-real-token")
|
||||
assert resp.status_code == 400
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "ungültig" in body.lower() or "abgelaufen" in body.lower()
|
||||
|
||||
|
||||
def test_manage_page_shows_invalid_message_for_unknown_booking(client):
|
||||
token = _mint_manage_token(CLIENT_A, "BK-does-not-exist")
|
||||
resp = client.get(f"/manage/{token}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_manage_page_shows_used_message_for_cancelled_booking(client):
|
||||
resource, service = _setup_resource_and_service()
|
||||
day = _next_monday(date.today())
|
||||
slot = client.get("/api/booking/slots", query_string={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"],
|
||||
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||
created = _create_booking(client, resource, service, slot)
|
||||
client.post("/api/booking/cancel", json={"token": created["token"]})
|
||||
|
||||
resp = client.get(f"/manage/{created['token']}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "storniert" in body.lower()
|
||||
|
||||
|
||||
def test_manage_page_renders_active_booking_with_manage_ui(client):
|
||||
resource, service = _setup_resource_and_service()
|
||||
day = _next_monday(date.today())
|
||||
slot = client.get("/api/booking/slots", query_string={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"],
|
||||
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||
created = _create_booking(client, resource, service, slot)
|
||||
|
||||
resp = client.get(f"/manage/{created['token']}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "Haircut" in body
|
||||
assert resource["resource_id"] in body
|
||||
assert created["token"] in body
|
||||
|
||||
|
||||
def test_manage_page_cancel_flow_reaches_cancel_api(client):
|
||||
resource, service = _setup_resource_and_service()
|
||||
day = _next_monday(date.today())
|
||||
slot = client.get("/api/booking/slots", query_string={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"],
|
||||
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||
created = _create_booking(client, resource, service, slot)
|
||||
|
||||
resp = client.post("/api/booking/cancel", json={"token": created["token"]})
|
||||
assert resp.status_code == 200
|
||||
assert bdb.get_booking(CLIENT_A, created["booking_id"])["status"] == "cancelled"
|
||||
|
||||
followup = client.get(f"/manage/{created['token']}")
|
||||
assert "storniert" in followup.get_data(as_text=True).lower()
|
||||
|
||||
|
||||
def test_manage_page_reschedule_slots_browsable_by_duration(client):
|
||||
"""The manage page's reschedule picker uses duration_minutes (not
|
||||
service_id, which bookings don't store) against /api/booking/slots."""
|
||||
resource, service = _setup_resource_and_service()
|
||||
day = _next_monday(date.today())
|
||||
slot = client.get("/api/booking/slots", query_string={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"service_id": service["service_id"],
|
||||
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||
created = _create_booking(client, resource, service, slot)
|
||||
|
||||
resp = client.get("/api/booking/slots", query_string={
|
||||
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||
"duration_minutes": "60", "exclude_booking_id": created["booking_id"],
|
||||
"date_from": day.isoformat(), "date_to": day.isoformat()})
|
||||
assert resp.status_code == 200
|
||||
slots = resp.get_json()["slots"]
|
||||
# The booking's own slot is excluded from the busy check, so it's
|
||||
# available again for a same-slot reschedule (a no-op success).
|
||||
assert slot in slots
|
||||
|
||||
reschedule_resp = client.post("/api/booking/reschedule", json={
|
||||
"token": created["token"], "start_time": slots[1]})
|
||||
assert reschedule_resp.status_code == 200
|
||||
@@ -28,6 +28,12 @@ services:
|
||||
ICS_TOKEN: ${ICS_TOKEN}
|
||||
SHEET_ID: ${SHEET_ID}
|
||||
GOOGLE_SA_JSON: /run/secrets/gcp-sa.json
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
MAIL_FALLBACK_FROM: ${MAIL_FALLBACK_FROM}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||
volumes:
|
||||
- ./secrets/gcp-sa.json:/run/secrets/gcp-sa.json:ro
|
||||
depends_on:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Caddy rate limiting for `/book/*` and `/api/booking/*` (#17)
|
||||
# Caddy rate limiting for `/book/*`, `/manage/*` and `/api/booking/*` (#17, #18)
|
||||
|
||||
Like every other homelab Caddy change (see `deploy/clients/new-client.sh`), there's no
|
||||
Caddyfile tracked in this repo -- apply this by hand at `/etc/caddy/Caddyfile` on the host
|
||||
and reload with `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
||||
|
||||
Add, on the site block that proxies to `smb-crm` (e.g. `onboard.mivanchenko.de`, or wherever
|
||||
`/book/` and `/api/booking/` are routed):
|
||||
`/book/`, `/manage/` and `/api/booking/` are routed):
|
||||
|
||||
```
|
||||
handle /book/* {
|
||||
@@ -18,6 +18,16 @@ handle /book/* {
|
||||
}
|
||||
reverse_proxy smb-crm:8080
|
||||
}
|
||||
handle /manage/* {
|
||||
rate_limit {
|
||||
zone book_public {
|
||||
key {remote_host}
|
||||
events 20
|
||||
window 1m
|
||||
}
|
||||
}
|
||||
reverse_proxy smb-crm:8080
|
||||
}
|
||||
handle /api/booking/* {
|
||||
rate_limit {
|
||||
zone book_api {
|
||||
|
||||
Reference in New Issue
Block a user