"""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 import booking_mail 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. 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: 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) 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")) 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) client = bdb.get_client(client_id) 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), duration_minutes, date_from, date_to, exclude_booking_id=exclude_booking_id) 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"]) # #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 @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()})