Rate-limit public bookings per contact; add direct owner contact endpoint
Test backoffice (smb-crm) / test (push) Successful in 1m43s

Public booking API now rejects a 6th active booking from the same
customer_contact within 24h (429), stopping one contact from filling
every slot on every resource, while owner-entered manual bookings stay
unaffected.

Add POST /api/contact: client sites can reach their own owner's inbox
directly (via their existing login email) for general inquiries,
separate from the agency's leads/Telegram pipeline (n8n/lead-intake.json),
which stays reserved for actual prospects contacting the agency itself.
Paris Barber Shop's contact form and Rückruf widget now point here; the
Rückruf floating widget itself has been removed from the site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 04:47:06 +02:00
parent bcb2672d9f
commit c3e520aabb
9 changed files with 299 additions and 108 deletions
+2
View File
@@ -18,6 +18,7 @@ import booking_db as bdb
import db
import owner_mail
from booking_api import bp as booking_bp
from contact_api import bp as contact_bp
from public_booking import bp as public_booking_bp
from manage_booking import bp as manage_booking_bp
from owner_auth import bp as owner_auth_bp
@@ -31,6 +32,7 @@ app = Flask(__name__, static_folder="static", static_url_path="")
# way to know the original request was HTTPS. One hop of proxy (Caddy).
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
app.register_blueprint(booking_bp)
app.register_blueprint(contact_bp)
app.register_blueprint(public_booking_bp)
app.register_blueprint(manage_booking_bp)
app.register_blueprint(owner_auth_bp)
+14
View File
@@ -24,6 +24,13 @@ bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
TOKEN_SECRET = os.environ.get("BOOKING_TOKEN_SECRET", "")
TOKEN_TTL_DAYS = 30
# Per-contact rate limit on the public booking endpoint: stops one phone
# number/email from filling every slot on every resource. Deliberately not
# applied to owner_booking.py's manual-entry path (source="owner") -- it
# calls create_booking_row directly, never through this route.
CONTACT_BOOKING_LIMIT = 5
CONTACT_BOOKING_WINDOW_HOURS = 24
def _mint_manage_token(client_id, booking_id):
payload = {
@@ -290,6 +297,13 @@ def create_booking():
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
since = datetime.now(timezone.utc) - timedelta(hours=CONTACT_BOOKING_WINDOW_HOURS)
recent = bdb.count_recent_bookings_by_contact(
client_id, body["customer_contact"], since)
if recent >= CONTACT_BOOKING_LIMIT:
return jsonify({"error": "too many recent bookings for this contact"}), 429
try:
booking, token = create_booking_row(
client_id, resource_id, service_id, start_time,
+16
View File
@@ -383,6 +383,22 @@ def list_active_bookings_for_resource(client_id, resource_id, start, end,
return cur.fetchall()
def count_recent_bookings_by_contact(client_id, customer_contact, since):
"""Count of client_id's non-cancelled bookings for customer_contact
created at or after `since` -- the public booking API's per-contact rate
limit reads this to stop one contact from filling every slot on every
resource. Owner-entered bookings (source="owner") count here too, since
an owner double-booking themselves in isn't the scenario this guards
against and excluding it would only add a footgun for no benefit."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT count(*) AS n FROM bookings WHERE client_id = %s "
"AND customer_contact = %s AND status != 'cancelled' "
"AND created_at >= %s",
(client_id, customer_contact, since))
return cur.fetchone()["n"]
def update_booking(client_id, booking_id, **fields):
"""Update a booking scoped to client_id (e.g. reschedule/cancel).
Returns the updated row, or None if no such booking exists for this
+56
View File
@@ -0,0 +1,56 @@
"""Public "contact the owner" API: a client's own site (a top-level page on
its own domain, e.g. barbershop.mivanchenko.de -- not an iframe of this app,
unlike the booking widget) posts here directly, cross-origin. See
contact_mail.py for why this is a separate path from n8n/lead-intake.json.
"""
from flask import Blueprint, jsonify, request
import booking_db as bdb
import contact_mail
bp = Blueprint("contact_api", __name__, url_prefix="/api/contact")
@bp.after_request
def _add_cors_headers(resp):
# Client sites live on their own domains (barbershop.mivanchenko.de, a
# future client's own domain, ...), never this app's own origin -- a
# fixed allowlist would mean editing this file for every new client, so
# this mirrors n8n/lead-intake.json's existing allowedOrigins: "*" for
# the same public, unauthenticated, abuse-limited-by-content form.
# Runs on every response from this blueprint, including Flask's
# automatic OPTIONS response to the browser's CORS preflight (a JSON
# POST isn't a CORS-simple request) -- no separate OPTIONS route needed.
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
return resp
@bp.post("")
def create_contact():
body = request.get_json(force=True, silent=True) or {}
if (body.get("website") or "").strip():
# Honeypot field, same convention as booking_api.py's create_booking:
# real visitors never see or fill it, so a filled value means a bot.
# Fake a normal-looking success so a scripted client has no signal.
return jsonify({"sent": True}), 201
client_id = body.get("client_id")
name = (body.get("name") or "").strip()
contact = (body.get("contact") or "").strip()
if not (client_id and name and contact):
return jsonify({"error": "client_id, name, contact are required"}), 400
if bdb.get_client(client_id) is None:
return jsonify({"error": "not found"}), 404
payload = {
"name": name,
"contact": contact,
"service_interest": (body.get("service_interest") or body.get("service") or "").strip(),
"message": (body.get("message") or "").strip(),
}
if not contact_mail.notify_owner_of_contact(client_id, payload):
return jsonify({"error": "not found"}), 404
return jsonify({"sent": True}), 201
+46
View File
@@ -0,0 +1,46 @@
"""Direct customer-to-owner contact email: a client's own site visitor asking
a question or requesting a callback (contact_api.py) reaches the business
owner's inbox directly. This is deliberately NOT the agency's leads pipeline
(n8n/lead-intake.json, the leads table, the agency's Telegram) -- that path
still exists separately for prospects contacting the agency itself (the
"DEMO" client_id on the marketing/demo pages), which are genuine sales leads
for the agency, not a client's own customers.
Recipient is resolved via booking_db.list_users(client_id) (the owner's own
login email, e.g. inhaber@<slug>.mivanchenko.de) -- every provisioned client
already has at least one owner account by the time their site can receive
contact requests. Fire-and-forget via mailer.py, same as booking_mail.py/
owner_mail.py.
"""
import booking_db as bdb
import booking_mail
import mailer
from flask import render_template
def notify_owner_of_contact(client_id, payload):
"""Returns True if at least one owner account was found and an email was
queued, False if this client has no owner account yet (nothing to notify
-- the caller turns that into a clean 404, since there is no one to
receive the message)."""
users = bdb.list_users(client_id)
if not users:
return False
client = bdb.get_client(client_id)
business_name = (client or {}).get("business_name") or "Ihre Website"
html = render_template(
"emails/contact_notification.html",
business_name=business_name,
name=payload.get("name") or "",
contact=payload.get("contact") or "",
service_interest=payload.get("service_interest") or "",
message=payload.get("message") or "",
)
from_addr = booking_mail._sender_for(client)
for user in users:
mailer.send_email(
user["email"],
f"Neue Anfrage über Ihre Website {business_name}",
html,
from_addr=from_addr)
return True
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Neue Anfrage</title>
</head>
<body style="margin:0; padding:20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#16302f;">
<h1 style="font-size:1.2rem; margin:0 0 14px;">{{ business_name }}</h1>
<p>Über Ihre Website ist eine neue Anfrage eingegangen:</p>
<table style="border-collapse:collapse; margin:14px 0;">
<tr><td style="padding:4px 12px 4px 0; color:#5b6b73;">Name</td><td style="padding:4px 0;"><b>{{ name }}</b></td></tr>
<tr><td style="padding:4px 12px 4px 0; color:#5b6b73;">Kontakt</td><td style="padding:4px 0;"><b>{{ contact }}</b></td></tr>
{% if service_interest %}<tr><td style="padding:4px 12px 4px 0; color:#5b6b73;">Interesse</td><td style="padding:4px 0;">{{ service_interest }}</td></tr>{% endif %}
</table>
{% if message %}<p style="white-space:pre-wrap;">{{ message }}</p>{% endif %}
<p style="color:#5b6b73; font-size:.85rem; margin-top:24px;">Bitte antworten Sie direkt an die oben genannte Kontaktadresse.</p>
</body>
</html>
+32
View File
@@ -185,6 +185,38 @@ def test_concurrent_booking_requests_only_one_succeeds(client):
assert len(bdb.list_bookings(CLIENT_A)) == 1
def test_contact_rate_limit_blocks_after_five_recent_bookings(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slots = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
assert len(slots) >= 6 # 09:00-17:00, 60min slots -- 8 available
for slot in slots[:5]:
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot,
"customer_name": "Serial Booker", "customer_contact": "serial@example.com"})
assert resp.status_code == 201
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slots[5],
"customer_name": "Serial Booker", "customer_contact": "serial@example.com"})
assert resp.status_code == 429
assert len(bdb.list_bookings(CLIENT_A)) == 5
# A different contact is unaffected by the first contact's count.
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slots[5],
"customer_name": "Someone Else", "customer_contact": "else@example.com"})
assert resp.status_code == 201
def test_cancel_with_valid_token_cancels_booking(client):
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
+112
View File
@@ -0,0 +1,112 @@
"""Flask test client / real-DB integration tests for the public "contact the
owner" API (contact_api.py) -- the customer-to-owner path, deliberately
separate from the agency's leads/Telegram pipeline. See contact_mail.py.
"""
import pytest
import booking_db as bdb
from app import app as flask_app
CLIENT_A = "C-TEST-CONTACT-A"
@pytest.fixture
def client():
flask_app.config["TESTING"] = True
return flask_app.test_client()
def _insert_booking_client(client_id, business_name="Café Test"):
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO clients (client_id, business_name) VALUES (%s, %s) "
"ON CONFLICT (client_id) DO NOTHING",
(client_id, business_name))
conn.commit()
def test_contact_request_emails_the_owner(client, monkeypatch):
sent = []
monkeypatch.setattr(
"contact_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(
{"to": to, "subject": subject, "html": html}))
_insert_booking_client(CLIENT_A)
bdb.create_user(CLIENT_A, "inhaber@contact-test.example", "irrelevant-pw")
resp = client.post("/api/contact", json={
"client_id": CLIENT_A, "name": "Jamie", "contact": "jamie@example.com",
"service_interest": "Herrenschnitt", "message": "Gibt es heute noch einen Termin?"})
assert resp.status_code == 201
assert resp.get_json() == {"sent": True}
assert resp.headers["Access-Control-Allow-Origin"] == "*"
assert len(sent) == 1
assert sent[0]["to"] == "inhaber@contact-test.example"
assert "Jamie" in sent[0]["html"]
assert "jamie@example.com" in sent[0]["html"]
assert "Herrenschnitt" in sent[0]["html"]
def test_contact_request_falls_back_to_service_field(client, monkeypatch):
"""The Rückruf widget sends service_interest; the plain contact form
sends the same concept under the field name "service" -- both work."""
sent = []
monkeypatch.setattr(
"contact_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(html))
client_id = "C-TEST-CONTACT-SVCFALLBACK"
_insert_booking_client(client_id)
bdb.create_user(client_id, "inhaber@svcfallback.example", "irrelevant-pw")
resp = client.post("/api/contact", json={
"client_id": client_id, "name": "Robin", "contact": "robin@example.com",
"service": "Bartpflege"})
assert resp.status_code == 201
assert "Bartpflege" in sent[0]
def test_honeypot_fakes_success_without_sending(client, monkeypatch):
sent = []
monkeypatch.setattr(
"contact_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(to))
client_id = "C-TEST-CONTACT-HONEYPOT"
_insert_booking_client(client_id)
bdb.create_user(client_id, "inhaber@honeypot.example", "irrelevant-pw")
resp = client.post("/api/contact", json={
"client_id": client_id, "name": "Bot", "contact": "bot@example.com",
"website": "http://spam.example"})
assert resp.status_code == 201
assert resp.get_json() == {"sent": True}
assert sent == []
def test_missing_required_fields_is_rejected(client):
resp = client.post("/api/contact", json={"client_id": CLIENT_A, "name": "NoContact"})
assert resp.status_code == 400
def test_unknown_client_is_rejected(client):
resp = client.post("/api/contact", json={
"client_id": "C-DOES-NOT-EXIST", "name": "Alex", "contact": "a@example.com"})
assert resp.status_code == 404
def test_client_without_owner_account_is_rejected(client):
"""A client that exists in the CRM but hasn't been provisioned into the
booking schema yet (no owner account) -- nothing to notify, so this is a
404 rather than a silent success the visitor would wrongly trust."""
client_id = "C-TEST-CONTACT-NOOWNER"
_insert_booking_client(client_id)
resp = client.post("/api/contact", json={
"client_id": client_id, "name": "Alex", "contact": "a@example.com"})
assert resp.status_code == 404
def test_preflight_options_returns_cors_headers(client):
resp = client.options("/api/contact")
assert resp.status_code < 300
assert resp.headers["Access-Control-Allow-Origin"] == "*"
assert "POST" in resp.headers["Access-Control-Allow-Methods"]