diff --git a/backoffice/.env.example b/backoffice/.env.example index 4f1ab8d..8fde97b 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -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 diff --git a/backoffice/app/app.py b/backoffice/app/app.py index 5d75f0a..4115c1d 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -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 diff --git a/backoffice/app/booking_api.py b/backoffice/app/booking_api.py index e1c3de3..d4ba2fa 100644 --- a/backoffice/app/booking_api.py +++ b/backoffice/app/booking_api.py @@ -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 diff --git a/backoffice/app/booking_mail.py b/backoffice/app/booking_mail.py new file mode 100644 index 0000000..05321ad --- /dev/null +++ b/backoffice/app/booking_mail.py @@ -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), + ) diff --git a/backoffice/app/mailer.py b/backoffice/app/mailer.py new file mode 100644 index 0000000..64ea1aa --- /dev/null +++ b/backoffice/app/mailer.py @@ -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) diff --git a/backoffice/app/manage_booking.py b/backoffice/app/manage_booking.py new file mode 100644 index 0000000..dedba1c --- /dev/null +++ b/backoffice/app/manage_booking.py @@ -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/") +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"], + ) diff --git a/backoffice/app/templates/emails/booking_confirmation.html b/backoffice/app/templates/emails/booking_confirmation.html new file mode 100644 index 0000000..ec2d16d --- /dev/null +++ b/backoffice/app/templates/emails/booking_confirmation.html @@ -0,0 +1,25 @@ + + + + + Terminbestätigung + + +

{{ business_name }}

+

Hallo {{ customer_name }},

+ {% if status == 'pending' %} +

Ihre Buchung wartet noch auf Bestätigung:

+ {% else %} +

Ihre Buchung ist bestätigt:

+ {% endif %} + +

+ Über den folgenden Link können Sie Ihren Termin jederzeit einsehen, verschieben oder + stornieren: +

+

{{ manage_url }}

+ + diff --git a/backoffice/app/templates/manage.html b/backoffice/app/templates/manage.html new file mode 100644 index 0000000..9631c6b --- /dev/null +++ b/backoffice/app/templates/manage.html @@ -0,0 +1,227 @@ + + + + + + Termin verwalten + + + + {% if state == "invalid" %} +

Termin verwalten

+
+

Dieser Link ist ungültig oder abgelaufen.

+

Bitte wenden Sie sich an das Unternehmen, wenn Sie Ihren Termin ändern + möchten.

+
+ + {% elif state == "used" %} +

Termin verwalten

+
+

Diese Buchung wurde bereits storniert.

+

Dieser Link kann nicht mehr verwendet werden.

+
+ + {% else %} +
+

Termin verwalten

+ +
+ +
+

Ihr Termin

+

+ {{ 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' }}) +

+ +
+ +
+

Termin verschieben

+
+ +
+
+ +
+ + +
+ + + {% endif %} + + diff --git a/backoffice/app/tests/test_booking_api.py b/backoffice/app/tests/test_booking_api.py index 097025b..4e57366 100644 --- a/backoffice/app/tests/test_booking_api.py +++ b/backoffice/app/tests/test_booking_api.py @@ -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 diff --git a/backoffice/app/tests/test_booking_mail.py b/backoffice/app/tests/test_booking_mail.py new file mode 100644 index 0000000..6f26ad4 --- /dev/null +++ b/backoffice/app/tests/test_booking_mail.py @@ -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 == [] diff --git a/backoffice/app/tests/test_mailer.py b/backoffice/app/tests/test_mailer.py new file mode 100644 index 0000000..ec43b92 --- /dev/null +++ b/backoffice/app/tests/test_mailer.py @@ -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("

hi

", 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", "

body

", + 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", "

body

") + assert captured[0]["From"] == "noreply@mivanchenko.de" diff --git a/backoffice/app/tests/test_manage_booking.py b/backoffice/app/tests/test_manage_booking.py new file mode 100644 index 0000000..16f2de9 --- /dev/null +++ b/backoffice/app/tests/test_manage_booking.py @@ -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 diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml index b4895d9..d950d6b 100644 --- a/backoffice/docker-compose.yml +++ b/backoffice/docker-compose.yml @@ -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: diff --git a/deploy/booking/RATE_LIMIT.md b/deploy/booking/RATE_LIMIT.md index 9d69894..e077c0c 100644 --- a/deploy/booking/RATE_LIMIT.md +++ b/deploy/booking/RATE_LIMIT.md @@ -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 {