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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user