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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user