"""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()})