From c3e520aabbaf77e668af9481e246653212c46e00 Mon Sep 17 00:00:00 2001 From: mivanchenko Date: Sat, 12 Sep 2026 04:47:06 +0200 Subject: [PATCH] Rate-limit public bookings per contact; add direct owner contact endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backoffice/app/app.py | 2 + backoffice/app/booking_api.py | 14 +++ backoffice/app/booking_db.py | 16 +++ backoffice/app/contact_api.py | 56 +++++++++ backoffice/app/contact_mail.py | 46 +++++++ .../emails/contact_notification.html | 18 +++ backoffice/app/tests/test_booking_api.py | 32 +++++ backoffice/app/tests/test_contact_api.py | 112 ++++++++++++++++++ .../clients/paris-barbershop/site/index.html | 111 +---------------- 9 files changed, 299 insertions(+), 108 deletions(-) create mode 100644 backoffice/app/contact_api.py create mode 100644 backoffice/app/contact_mail.py create mode 100644 backoffice/app/templates/emails/contact_notification.html create mode 100644 backoffice/app/tests/test_contact_api.py diff --git a/backoffice/app/app.py b/backoffice/app/app.py index 69e13ca..299d402 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -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) diff --git a/backoffice/app/booking_api.py b/backoffice/app/booking_api.py index 81de4af..da95849 100644 --- a/backoffice/app/booking_api.py +++ b/backoffice/app/booking_api.py @@ -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, diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index 226698a..477d986 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -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 diff --git a/backoffice/app/contact_api.py b/backoffice/app/contact_api.py new file mode 100644 index 0000000..f1a46f3 --- /dev/null +++ b/backoffice/app/contact_api.py @@ -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 diff --git a/backoffice/app/contact_mail.py b/backoffice/app/contact_mail.py new file mode 100644 index 0000000..da2bfd9 --- /dev/null +++ b/backoffice/app/contact_mail.py @@ -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@.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 diff --git a/backoffice/app/templates/emails/contact_notification.html b/backoffice/app/templates/emails/contact_notification.html new file mode 100644 index 0000000..2d58674 --- /dev/null +++ b/backoffice/app/templates/emails/contact_notification.html @@ -0,0 +1,18 @@ + + + + + Neue Anfrage + + +

{{ business_name }}

+

Über Ihre Website ist eine neue Anfrage eingegangen:

+ + + + {% if service_interest %}{% endif %} +
Name{{ name }}
Kontakt{{ contact }}
Interesse{{ service_interest }}
+ {% if message %}

{{ message }}

{% endif %} +

Bitte antworten Sie direkt an die oben genannte Kontaktadresse.

+ + diff --git a/backoffice/app/tests/test_booking_api.py b/backoffice/app/tests/test_booking_api.py index ddf0ecb..e2b188f 100644 --- a/backoffice/app/tests/test_booking_api.py +++ b/backoffice/app/tests/test_booking_api.py @@ -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) diff --git a/backoffice/app/tests/test_contact_api.py b/backoffice/app/tests/test_contact_api.py new file mode 100644 index 0000000..6a9c466 --- /dev/null +++ b/backoffice/app/tests/test_contact_api.py @@ -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"] diff --git a/deploy/clients/paris-barbershop/site/index.html b/deploy/clients/paris-barbershop/site/index.html index 4f611e9..cfeeb8f 100644 --- a/deploy/clients/paris-barbershop/site/index.html +++ b/deploy/clients/paris-barbershop/site/index.html @@ -231,7 +231,7 @@
Kontakt -

Frage stellen oder Rückruf anfordern

+

Frage stellen

Schreiben Sie uns kurz — wir melden uns am selben Tag.

@@ -375,8 +375,8 @@ - - - - - - -
- -
- - - -