[31988f42] Feat: Implemented live calendar check (race-condition prevention) and iframe-ready HTML responses for WP integration

This commit is contained in:
2026-03-09 09:19:35 +00:00
parent 895e8b5c19
commit 90219ebfca
2 changed files with 114 additions and 5 deletions

View File

@@ -244,6 +244,8 @@ services:
CAL_TENNANT_ID: "${CAL_TENNANT_ID}" CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}" TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}" FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
volumes: volumes:
- ./lead-engine:/app - ./lead-engine:/app
- lead_engine_data:/app/data - lead_engine_data:/app/data

View File

@@ -11,6 +11,7 @@ from threading import Thread, Lock
import uvicorn import uvicorn
import logging import logging
from fastapi import FastAPI, Response, BackgroundTasks from fastapi import FastAPI, Response, BackgroundTasks
from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -44,6 +45,8 @@ SessionLocal = init_db(f"sqlite:///{DB_FILE_PATH}")
# --- Config --- # --- Config ---
TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "") TEAMS_WEBHOOK_URL = os.getenv("TEAMS_WEBHOOK_URL", "")
FEEDBACK_SERVER_BASE_URL = os.getenv("FEEDBACK_SERVER_BASE_URL", "http://localhost:8004") 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 DEFAULT_WAIT_MINUTES = 5
SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de") SENDER_EMAIL = os.getenv("SENDER_EMAIL", "info@robo-planet.de")
TEST_RECEIVER_EMAIL = "floke.com@gmail.com" TEST_RECEIVER_EMAIL = "floke.com@gmail.com"
@@ -128,6 +131,36 @@ def find_slots(start, view, interval):
break break
return slots 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): def create_calendar_invite(lead_email, company, start_time):
catchall = os.getenv("EMAIL_CATCHALL"); lead_email = catchall if catchall else lead_email 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) 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.") if job: job.status = "send_now"; db.commit(); db.close(); return Response("Wird gesendet.")
db.close(); return Response("Not Found", 404) db.close(); return Response("Not Found", 404)
SUCCESS_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; text-align: center; color: #333; padding: 40px 20px; background: transparent; }
.icon { font-size: 48px; margin-bottom: 20px; }
h2 { margin-bottom: 10px; color: #10b981; }
p { font-size: 16px; line-height: 1.5; color: #4b5563; }
.details { font-weight: bold; margin-top: 15px; color: #111827; }
</style>
</head>
<body>
<div class="icon">✅</div>
<h2>Termin erfolgreich gebucht!</h2>
<p>Vielen Dank für die Terminbuchung.</p>
<p class="details">Wir bestätigen Ihren Termin am {date} um {time} Uhr.</p>
<p>Sie erhalten in Kürze eine separate Kalendereinladung inkl. Microsoft Teams-Link an Ihre E-Mail-Adresse.</p>
</body>
</html>
"""
FALLBACK_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; text-align: center; color: #333; margin: 0; padding: 0; background: transparent; }
.message-box { padding: 20px; background-color: #fef3c7; color: #92400e; border-bottom: 1px solid #fcd34d; margin-bottom: 20px; }
h3 { margin: 0 0 10px 0; font-size: 18px; }
p { margin: 0; font-size: 14px; }
.iframe-container { width: 100%; height: 800px; border: none; }
</style>
</head>
<body>
<div class="message-box">
<h3>⚠️ Termin leider nicht mehr verfügbar</h3>
<p>Der von Ihnen gewählte Termin wurde in der Zwischenzeit leider anderweitig vergeben.<br>
Bitte wählen Sie direkt hier einen neuen, passenden Termin aus unserem Kalender:</p>
</div>
<!-- BITTE DEN ECHTEN MS BOOKINGS LINK EINTRAGEN -->
<iframe class="iframe-container" src="{ms_bookings_url}" scrolling="yes"></iframe>
</body>
</html>
"""
@app.get("/book_slot/{job_uuid}/{ts}") @app.get("/book_slot/{job_uuid}/{ts}")
def book_slot(job_uuid: str, ts: int): def book_slot(job_uuid: str, ts: int):
slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN) slot_time = datetime.fromtimestamp(ts, tz=TZ_BERLIN)
db = SessionLocal(); job = db.query(ProposalJob).filter(ProposalJob.job_uuid == job_uuid).first() db = SessionLocal()
if not job or job.status == "booked": db.close(); return Response("Fehler.", 400) 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): if create_calendar_invite(job.customer_email, job.customer_company, slot_time):
job.status = "booked"; db.commit(); db.close(); return Response(f"Gebucht!") job.status = "booked"
db.close(); return Response("Fehler bei Kalender.", 500) 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="<p>Es gab einen internen Fehler bei der Kalenderbuchung. Bitte versuchen Sie es später erneut.</p>", status_code=500)
# --- Workflow Logic --- # --- Workflow Logic ---
def send_email(subject, body, to_email): 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.") logging.info(f"Timeout reached or 'Send Now' clicked for job {request_id}. Proceeding to send email.")
booking_html = "<ul>" booking_html = "<ul>"
for s in suggestions: for s in suggestions:
booking_html += f'<li><a href="{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}">{format_date_for_email(s)}</a></li>' if WORDPRESS_BOOKING_URL:
link = f"{WORDPRESS_BOOKING_URL}?job_uuid={request_id}&ts={int(s.timestamp())}"
else:
link = f"{FEEDBACK_SERVER_BASE_URL}/book_slot/{request_id}/{int(s.timestamp())}"
booking_html += f'<li><a href="{link}">{format_date_for_email(s)}</a></li>'
booking_html += "</ul>" booking_html += "</ul>"
try: try: