644c99ee30
Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py) on booking creation, with a manage-booking link embedding the ticket-2 signed token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses the existing slot-picker against booking_api's create/cancel/reschedule API, distinguishing an invalid/expired link from an already-cancelled one. Sender address uses the client's own domain when configured, falling back to a mivanchenko.de address otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
261 lines
11 KiB
Python
261 lines
11 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
|
|
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()})
|