Public booking page + iframe embed (#17)

Adds the customer-facing /book/<slug> page: service/slot picker, booking
form, and confirmation screen, built on #16's existing booking JSON API.
Includes iframe auto-fit height reporting (mirroring
deploy/booking/booking_layout.js's eaBookingHeight message), brand-color
theming via a ?color= query param, a honeypot field with a fake-success
response indistinguishable from a real booking, and a clear "just taken"
message on slot-conflict. Caddy per-IP rate limiting is documented in
deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is
tracked in this repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:15:29 +02:00
parent 2f6e0c1459
commit b895663c3a
7 changed files with 556 additions and 5 deletions
+31 -5
View File
@@ -26,15 +26,23 @@ class UnknownResource(Exception):
guards against a booking write smuggling in another tenant's resource."""
def _new_id(prefix):
def new_id(prefix):
return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}"
def _list_active(table, client_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
f"SELECT * FROM {table} WHERE client_id = %s AND active ORDER BY name",
(client_id,))
return cur.fetchall()
# ---- resources ----
def create_resource(client_id, name, active=True, min_notice_minutes=60,
max_advance_days=30, buffer_minutes=0):
resource_id = _new_id("RS")
resource_id = new_id("RS")
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO resources (resource_id, client_id, name, active, "
@@ -90,7 +98,7 @@ def get_resource_hours(client_id, resource_id):
# ---- services ----
def create_service(client_id, name, duration_minutes, price=None, active=True):
service_id = _new_id("SV")
service_id = new_id("SV")
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO services (service_id, client_id, name, duration_minutes, "
@@ -109,6 +117,14 @@ def get_service(client_id, service_id):
return cur.fetchone()
def list_active_services(client_id):
return _list_active("services", client_id)
def list_active_resources(client_id):
return _list_active("resources", client_id)
# ---- bookings ----
_BOOKING_UPDATABLE = {"resource_id", "customer_name", "customer_contact",
@@ -126,7 +142,7 @@ def create_booking(client_id, resource_id, customer_name, customer_contact,
into a clean error."""
if get_resource(client_id, resource_id) is None:
raise UnknownResource(f"no resource {resource_id} for client {client_id}")
booking_id = _new_id("BK")
booking_id = new_id("BK")
try:
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
@@ -224,10 +240,20 @@ def get_client(client_id):
return cur.fetchone()
def get_client_by_slug(slug):
"""Resolve a client for the public /book/<slug> page. slug is
unauthenticated user input, so this is the one lookup that goes straight
from an untrusted string to a client_id -- every other public-booking
call still requires the resolved client_id explicitly."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM clients WHERE slug = %s", (slug,))
return cur.fetchone()
# ---- users (owner login) ----
def create_user(client_id, email, password):
user_id = _new_id("U")
user_id = new_id("U")
password_hash = generate_password_hash(password)
with db.connect() as conn, conn.cursor() as cur:
cur.execute(