Files
smb-online/backoffice/app/booking_api.py
T
mivanchenko 2f6e0c1459 Availability engine + booking API: create/cancel/reschedule (#16)
Adds the plumbing that makes "can a customer actually get booked" true
end to end at the API layer, on top of #15's schema/tenancy layer.

- resource_hours table + min_notice_minutes/max_advance_days/buffer_minutes
  on resources -- config #15 didn't include but #16 depends on.
- availability.py: pure slot-generation function, correct across a
  Europe/Berlin DST transition (tested both directions).
- booking_api.py: JSON blueprint for slot listing, booking creation
  (auto_confirm -> confirmed/pending), and signed-JWT cancel/reschedule,
  registered into app.py.
- booking_db.py gains resource-hours CRUD, a tenant-scoped busy-bookings
  query for buffer/slot validation, and a read-only client lookup.

A true concurrent-threads test (not just sequential requests) surfaced a
real gap: Postgres can raise DeadlockDetected instead of ExclusionViolation
when two overlapping inserts race the exclusion constraint directly, which
went uncaught and would have 500'd instead of giving the clean 4xx the
ticket requires -- now caught alongside ExclusionViolation.

Also fixed: reschedule used the request's raw UTC offset to pick the
business day instead of the client's own timezone (could pick the wrong
day's hours/bookings near local midnight); the cancel/reschedule JWT no
longer falls back to reusing CRM_API_TOKEN as its signing secret.

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

210 lines
8.6 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 {}
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()})