Files
smb-online/backoffice/app/booking_api.py
T
mivanchenko b895663c3a Public booking page + iframe embed (#17)
Adds the customer-facing /book/<slug> page: service/slot picker, booking
form, and confirmation screen, built on #16's existing booking JSON API.
Includes iframe auto-fit height reporting (mirroring
deploy/booking/booking_layout.js's eaBookingHeight message), brand-color
theming via a ?color= query param, a honeypot field with a fake-success
response indistinguishable from a real booking, and a clear "just taken"
message on slot-conflict. Caddy per-IP rate limiting is documented in
deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is
tracked in this repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 15:15:29 +02:00

223 lines
9.5 KiB
Python

"""Booking API blueprint (#16): availability + create/cancel/reschedule.
Headless JSON API -- no browser UI yet (that's #17/#18). Routes here are the
only place that mints/verifies the signed cancel/reschedule token and the
only caller of the availability engine; all DB access still goes through
booking_db.py.
"""
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import jwt
from flask import Blueprint, jsonify, request
import availability
import booking_db as bdb
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
# Dedicated secret -- deliberately not shared with CRM_API_TOKEN, so rotating
# one never silently invalidates (or, worse, cross-signs) the other.
TOKEN_SECRET = os.environ.get("BOOKING_TOKEN_SECRET", "")
TOKEN_TTL_DAYS = 30
def _mint_manage_token(client_id, booking_id):
payload = {
"client_id": client_id,
"booking_id": booking_id,
"exp": datetime.now(timezone.utc) + timedelta(days=TOKEN_TTL_DAYS),
}
return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256")
def _verify_manage_token(token):
"""Returns (client_id, booking_id), or None if the token is
missing/expired/malformed."""
try:
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
return None
return payload.get("client_id"), payload.get("booking_id")
def _parse_dt(value):
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
def _parse_date(value):
try:
return datetime.fromisoformat(str(value)).date()
except (ValueError, TypeError):
return None
def _tz_name(client):
return (client or {}).get("timezone") or "Europe/Berlin"
def _local_date(dt, tz_name):
"""The calendar date dt falls on in tz_name -- used to pick the right
business day (and the right resource_hours row) regardless of what UTC
offset the caller's ISO string happened to use."""
return dt.astimezone(ZoneInfo(tz_name)).date()
def _available_slots(client_id, resource, tz_name, duration_minutes, date_from,
date_to, exclude_booking_id=None):
hours = bdb.get_resource_hours(client_id, resource["resource_id"])
# date_from/date_to are local calendar dates -- widen the busy-booking
# query to the UTC instants that actually cover them in tz_name, not a
# literal UTC midnight window (which would miss/misalign bookings near
# local midnight, e.g. in winter Berlin midnight is 23:00 UTC the day
# before).
tz = ZoneInfo(tz_name)
day_start = datetime.combine(date_from, datetime.min.time(), tzinfo=tz).astimezone(timezone.utc)
day_end = (datetime.combine(date_to, datetime.min.time(), tzinfo=tz)
+ timedelta(days=1)).astimezone(timezone.utc)
busy_rows = bdb.list_active_bookings_for_resource(
client_id, resource["resource_id"], day_start, day_end,
exclude_booking_id=exclude_booking_id)
busy = [(r["start_time"], r["end_time"]) for r in busy_rows]
return availability.generate_slots(
hours, duration_minutes, date_from, date_to, tz_name,
now=datetime.now(timezone.utc),
min_notice_minutes=resource["min_notice_minutes"],
max_advance_days=resource["max_advance_days"],
buffer_minutes=resource["buffer_minutes"],
busy=busy)
@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")
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
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:
return jsonify({"error": "not found"}), 404
slot_list = _available_slots(client_id, resource, _tz_name(client),
service["duration_minutes"], date_from, date_to)
return jsonify({"slots": [s.isoformat() for s in slot_list]})
@bp.post("")
def create_booking():
body = request.get_json(force=True, silent=True) or {}
if (body.get("website") or "").strip():
# Honeypot field: real customers never see or fill it (hidden from
# sighted users and screen readers alike), so a filled value means a
# scripted bot filled every field it could find. Fake a normal-looking
# success instead of a 4xx so a scripted client has no signal it was
# caught -- no booking is created, but the id/token are shaped exactly
# like a real create_booking() response (same id format, same client_id
# in the token's claims) so nothing about this response is
# distinguishable from a genuine one by a client inspecting it.
fake_booking_id = bdb.new_id("BK")
return jsonify({"booking_id": fake_booking_id, "status": "confirmed",
"token": _mint_manage_token(body.get("client_id") or "",
fake_booking_id)}), 201
client_id = body.get("client_id")
resource_id = body.get("resource_id")
service_id = body.get("service_id")
start_time = _parse_dt(body.get("start_time"))
if not (client_id and resource_id and service_id and start_time
and body.get("customer_name") and body.get("customer_contact")):
return jsonify({"error": "client_id, resource_id, service_id, start_time, "
"customer_name, customer_contact 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:
return jsonify({"error": "not found"}), 404
tz_name = _tz_name(client)
day = _local_date(start_time, tz_name)
valid_starts = _available_slots(client_id, resource, tz_name,
service["duration_minutes"], day, day)
if start_time not in valid_starts:
return jsonify({"error": "that slot is no longer available"}), 409
end_time = start_time + timedelta(minutes=service["duration_minutes"])
status = "confirmed" if client.get("auto_confirm", True) else "pending"
try:
booking = bdb.create_booking(
client_id, resource_id, body["customer_name"], body["customer_contact"],
service["name"], start_time, end_time, source="public", status=status)
except bdb.BookingConflict:
return jsonify({"error": "that slot was just taken"}), 409
token = _mint_manage_token(client_id, booking["booking_id"])
return jsonify({"booking_id": booking["booking_id"], "status": booking["status"],
"token": token}), 201
@bp.post("/cancel")
def cancel_booking():
body = request.get_json(force=True, silent=True) or {}
resolved = _verify_manage_token(body.get("token"))
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
existing = bdb.get_booking(client_id, booking_id)
if existing is None:
return jsonify({"error": "not found"}), 404
if existing["status"] == "cancelled":
return jsonify({"error": "already cancelled"}), 409
updated = bdb.update_booking(client_id, booking_id, status="cancelled")
return jsonify({"cancelled": updated["booking_id"]})
@bp.post("/reschedule")
def reschedule_booking():
body = request.get_json(force=True, silent=True) or {}
resolved = _verify_manage_token(body.get("token"))
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
new_start = _parse_dt(body.get("start_time"))
if new_start is None:
return jsonify({"error": "start_time is required"}), 400
existing = bdb.get_booking(client_id, booking_id)
if existing is None:
return jsonify({"error": "not found"}), 404
if existing["status"] == "cancelled":
return jsonify({"error": "already cancelled"}), 409
duration = existing["end_time"] - existing["start_time"]
new_end = new_start + duration
resource = bdb.get_resource(client_id, existing["resource_id"])
client = bdb.get_client(client_id)
if resource is None or client is None:
return jsonify({"error": "not found"}), 404
tz_name = _tz_name(client)
day = _local_date(new_start, tz_name)
valid_starts = _available_slots(client_id, resource, tz_name,
duration.total_seconds() // 60, day, day,
exclude_booking_id=booking_id)
if new_start not in valid_starts:
return jsonify({"error": "that slot is no longer available"}), 409
try:
updated = bdb.update_booking(client_id, booking_id, start_time=new_start,
end_time=new_end)
except bdb.BookingConflict:
return jsonify({"error": "that slot was just taken"}), 409
return jsonify({"booking_id": updated["booking_id"],
"start_time": updated["start_time"].isoformat(),
"end_time": updated["end_time"].isoformat()})