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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""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()})
|
||||
Reference in New Issue
Block a user