From 68ad81889334b40b057197acc0d5f92b5747c5a9 Mon Sep 17 00:00:00 2001 From: Floke Date: Mon, 9 Mar 2026 09:19:35 +0000 Subject: [PATCH] [31988f42] Feat: Implemented live calendar check (race-condition prevention) and iframe-ready HTML responses for WP integration --- docker-compose.yml | 2 + lead-engine/trading_twins/manager.py | 117 +++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index df3326a8..e0a6fd9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -244,6 +244,8 @@ services: CAL_TENNANT_ID: "${CAL_TENNANT_ID}" TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}" FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}" + WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}" + MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}" volumes: - ./lead-engine:/app - lead_engine_data:/app/data diff --git a/lead-engine/trading_twins/manager.py b/lead-engine/trading_twins/manager.py index f824a3ae..3e070ba4 100644 --- a/lead-engine/trading_twins/manager.py +++ b/lead-engine/trading_twins/manager.py @@ -11,6 +11,7 @@ from threading import Thread, Lock import uvicorn import logging from fastapi import FastAPI, Response, BackgroundTasks +from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlalchemy.orm import sessionmaker @@ -44,6 +45,8 @@ SessionLocal = init_db(f"sqlite:///{DB_FILE_PATH}") # --- Config --- TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "") FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "http://localhost:8004") +WORDPRESS_BOOKING_URL = os.getenv("WORDPRESS_BOOKING_URL", "") +MS_BOOKINGS_URL = os.getenv("MS_BOOKINGS_URL", "https://outlook.office365.com/owa/calendar/IHR_BOOKINGS_NAME@robo-planet.de/bookings/") DEFAULT_WAIT_MINUTES = 5 SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de") TEST_RECEIVER_EMAIL = "floke.com@gmail.com" @@ -128,6 +131,36 @@ def find_slots(start, view, interval): break return slots +def is_slot_free(target_email: str, app_creds: tuple, target_time: datetime) -> bool: + """Checks via MS Graph API if a specific 15-minute slot is still free.""" + logging.info(f"Live-Checking slot availability for {target_email} at {target_time}") + token = get_access_token(*app_creds) + if not token: + logging.warning("Failed to get token for live-check. Proceeding anyway.") + return True # Fallback: assume free to not block booking + + end_time = target_time + timedelta(minutes=15) + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Prefer": 'outlook.timezone="Europe/Berlin"'} + payload = { + "schedules": [target_email], + "startTime": {"dateTime": target_time.isoformat(), "timeZone": "Europe/Berlin"}, + "endTime": {"dateTime": end_time.isoformat(), "timeZone": "Europe/Berlin"}, + "availabilityViewInterval": 15 + } + try: + url = f"{GRAPH_API_ENDPOINT}/users/{target_email}/calendar/getSchedule" + r = requests.post(url, headers=headers, json=payload) + if r.status_code == 200: + view = r.json().get('value', [{}])[0].get('availabilityView', '') + if view: + logging.info(f"Live-Check view for {target_time}: '{view}'") + return view[0] == '0' # '0' means free + else: + logging.error(f"Live-check failed with status {r.status_code}: {r.text}") + except Exception as e: + logging.error(f"Live-check threw an exception: {e}") + return True # Fallback + def create_calendar_invite(lead_email, company, start_time): catchall = os.getenv("EMAIL_CATCHALL"); lead_email = catchall if catchall else lead_email token = get_access_token(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) @@ -182,14 +215,84 @@ def send_now(job_uuid: str): if job: job.status = "send_now"; db.commit(); db.close(); return Response("Wird gesendet.") db.close(); return Response("Not Found", 404) +SUCCESS_HTML = """ + + + + + + + +
+

Termin erfolgreich gebucht!

+

Vielen Dank für die Terminbuchung.

+

Wir bestätigen Ihren Termin am {date} um {time} Uhr.

+

Sie erhalten in Kürze eine separate Kalendereinladung inkl. Microsoft Teams-Link an Ihre E-Mail-Adresse.

+ + +""" + +FALLBACK_HTML = """ + + + + + + + +
+

⚠️ Termin leider nicht mehr verfügbar

+

Der von Ihnen gewählte Termin wurde in der Zwischenzeit leider anderweitig vergeben.
+ Bitte wählen Sie direkt hier einen neuen, passenden Termin aus unserem Kalender:

+
+ + + + +""" + @app.get("/book_slot/{job_uuid}/{ts}") def book_slot(job_uuid: str, ts: int): slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN) - db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first() - if not job or job.status == "booked": db.close(); return Response("Fehler.", 400) + db = SessionLocal() + job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first() + + if not job or job.status == "booked": + db.close() + # If job doesn't exist or is already booked, show the fallback calendar to allow a new booking safely + return HTMLResponse(content=FALLBACK_HTML.format(ms_bookings_url=MS_BOOKINGS_URL)) + + # LIVE CHECK: Is the slot still free in the calendar? + app_creds = (CAL_APPID, CAL_SECRET, CAL_TENNANT_ID) + if not is_slot_free("e.melcer@robo-planet.de", app_creds, slot_time): + logging.warning(f"RACE CONDITION PREVENTED: Slot {slot_time} for job {job_uuid} is taken!") + db.close() + return HTMLResponse(content=FALLBACK_HTML.format(ms_bookings_url=MS_BOOKINGS_URL)) + if create_calendar_invite(job.customer_email, job.customer_company, slot_time): - job.status = "booked"; db.commit(); db.close(); return Response(f"Gebucht!") - db.close(); return Response("Fehler bei Kalender.", 500) + job.status = "booked" + db.commit() + db.close() + + date_str = slot_time.strftime('%d.%m.%Y') + time_str = slot_time.strftime('%H:%M') + html_content = SUCCESS_HTML.format(date=date_str, time=time_str) + return HTMLResponse(content=html_content) + + db.close() + return HTMLResponse(content="

Es gab einen internen Fehler bei der Kalenderbuchung. Bitte versuchen Sie es später erneut.

", status_code=500) # --- Workflow Logic --- def send_email(subject, body, to_email): @@ -301,7 +404,11 @@ def process_lead(request_id, company, opener, receiver, name): logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.") booking_html = "" try: