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:
2026-07-23 15:40:45 +02:00
parent b895663c3a
commit 644c99ee30
14 changed files with 872 additions and 15 deletions
+48 -10
View File
@@ -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