[31988f42] Feat: Implemented live calendar check (race-condition prevention) and iframe-ready HTML responses for WP integration
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user