From 7b010ce65ea6ba4807611bdf485e7fd1c49d8664 Mon Sep 17 00:00:00 2001 From: Jonas Linter <{email_address}> Date: Tue, 25 Nov 2025 20:19:48 +0100 Subject: [PATCH] Moved existing processing functions to webhook_processor --- src/alpine_bits_python/webhook_processor.py | 207 +++++++++++++++++++- 1 file changed, 205 insertions(+), 2 deletions(-) diff --git a/src/alpine_bits_python/webhook_processor.py b/src/alpine_bits_python/webhook_processor.py index 05a6fb1..0359679 100644 --- a/src/alpine_bits_python/webhook_processor.py +++ b/src/alpine_bits_python/webhook_processor.py @@ -1,7 +1,8 @@ """Webhook processor interface and implementations.""" from abc import ABC, abstractmethod -from datetime import datetime +import asyncio +from datetime import date, datetime from typing import Any, Protocol from fastapi import HTTPException, Request @@ -81,6 +82,209 @@ class WebhookProcessorRegistry: return self._processors.get(webhook_type) +async def process_wix_form_submission(request: Request, data: dict[str, Any], db): + """Shared business logic for handling Wix form submissions (test and production).""" + timestamp = datetime.now().isoformat() + + _LOGGER.info("Received Wix form data at %s", timestamp) + + data = data.get("data") # Handle nested "data" key if present + + # save customer and reservation to DB + + contact_info = data.get("contact", {}) + first_name = contact_info.get("name", {}).get("first") + last_name = contact_info.get("name", {}).get("last") + email = contact_info.get("email") + phone_number = contact_info.get("phones", [{}])[0].get("e164Phone") + contact_info.get("locale", "de-de") + contact_id = contact_info.get("contactId") + + name_prefix = data.get("field:anrede") + + email_newsletter = data.get("field:form_field_5a7b", False) + + # if email_newsletter is a string, attempt to convert to boolean, else false + if isinstance(email_newsletter, str): + email_newsletter = email_newsletter.lower() in [ + "yes", + "true", + "1", + "on", + "selezionato", + "angekreuzt", + ] + + address_line = None + city_name = None + postal_code = None + country_code = None + gender = None + birth_date = None + language = data.get("contact", {}).get("locale", "en")[:2] + + # Dates + start_date = ( + data.get("field:date_picker_a7c8") + or data.get("Anreisedatum") + or data.get("submissions", [{}])[1].get("value") + ) + end_date = ( + data.get("field:date_picker_7e65") + or data.get("Abreisedatum") + or data.get("submissions", [{}])[2].get("value") + ) + + # Room/guest info + num_adults = int(data.get("field:number_7cf5") or 1) + num_children = int(data.get("field:anzahl_kinder") or 0) + children_ages = [] + if num_children > 0: + # Collect all child age fields, then take only the first num_children + # This handles form updates that may send extra padded/zero fields + temp_ages = [] + for k in data: + if k.startswith("field:alter_kind_"): + if data[k] is None or data[k] == "": + continue + try: + age = int(data[k]) + temp_ages.append(age) + except ValueError: + _LOGGER.warning("Invalid age value for %s: %s", k, data[k]) + + # Only keep the first num_children ages, regardless of their values + children_ages = temp_ages[:num_children] + + offer = data.get("field:angebot_auswaehlen") + + # get submissionId and ensure max length 35. Generate one if not present + + unique_id = data.get("submissionId", generate_unique_id()) + + # Use CustomerService to handle customer creation/update with hashing + customer_service = CustomerService(db) + + customer_data = { + "given_name": first_name, + "surname": last_name, + "contact_id": contact_id, + "name_prefix": name_prefix, + "email_address": email, + "phone": phone_number, + "email_newsletter": email_newsletter, + "address_line": address_line, + "city_name": city_name, + "postal_code": postal_code, + "country_code": country_code, + "gender": gender, + "birth_date": birth_date, + "language": language, + "address_catalog": False, + "name_title": None, + } + + # This automatically creates/updates both Customer and HashedCustomer + db_customer = await customer_service.get_or_create_customer(customer_data) + + # Determine hotel_code and hotel_name + # Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback + hotel_code = data.get("field:hotelid", None) + + if hotel_code is None: + _LOGGER.warning("No hotel_code provided in form data, using default") + + hotel_code = request.app.state.config.get("default_hotel_code", "123") + + hotel_name = ( + data.get("field:hotelname") + or data.get("hotelname") + or request.app.state.config.get("default_hotel_name") + or "Frangart Inn" # fallback + ) + + submissionTime = data.get("submissionTime") # 2025-10-07T05:48:41.855Z + try: + if submissionTime: + submissionTime = datetime.fromisoformat( + submissionTime[:-1] + ) # Remove Z and convert + except Exception as e: + _LOGGER.exception("Error parsing submissionTime: %s", e) + submissionTime = None + + # Extract fbclid and gclid for conditional account ID lookup + fbclid = data.get("field:fbclid") + gclid = data.get("field:gclid") + + # Get advertising account IDs conditionally based on fbclid/gclid presence + meta_account_id, google_account_id = get_advertising_account_ids( + request.app.state.config, hotel_code, fbclid, gclid + ) + + reservation = ReservationData( + unique_id=unique_id, + start_date=date.fromisoformat(start_date), + end_date=date.fromisoformat(end_date), + num_adults=num_adults, + num_children=num_children, + children_ages=children_ages, + hotel_code=hotel_code, + hotel_name=hotel_name, + offer=offer, + created_at=submissionTime, + utm_source=data.get("field:utm_source"), + utm_medium=data.get("field:utm_medium"), + utm_campaign=data.get("field:utm_campaign"), + utm_term=data.get("field:utm_term"), + utm_content=data.get("field:utm_content"), + user_comment=data.get("field:long_answer_3524", ""), + fbclid=fbclid, + gclid=gclid, + meta_account_id=meta_account_id, + google_account_id=google_account_id, + ) + + if reservation.md5_unique_id is None: + raise HTTPException(status_code=400, detail="Failed to generate md5_unique_id") + + # Use ReservationService to create reservation + reservation_service = ReservationService(db) + db_reservation = await reservation_service.create_reservation( + reservation, db_customer.id + ) + + async def push_event(): + # Fire event for listeners (push, etc.) - hotel-specific dispatch + dispatcher = getattr(request.app.state, "event_dispatcher", None) + if dispatcher: + # Get hotel_code from reservation to target the right listeners + hotel_code = getattr(db_reservation, "hotel_code", None) + if hotel_code and hotel_code.strip(): + await dispatcher.dispatch_for_hotel( + "form_processed", hotel_code, db_customer, db_reservation + ) + _LOGGER.info("Dispatched form_processed event for hotel %s", hotel_code) + else: + _LOGGER.warning( + "No hotel_code in reservation, skipping push notifications" + ) + + # Create task and store reference to prevent it from being garbage collected + # The task runs independently and we don't need to await it here + task = asyncio.create_task(push_event()) + # Add done callback to log any exceptions that occur in the background task + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + + return { + "status": "success", + "message": "Wix form data received successfully", + "received_keys": list(data.keys()), + "timestamp": timestamp, + "note": "No authentication required for this endpoint", + } + + class WixFormProcessor: """Processor for Wix form webhooks.""" @@ -110,7 +314,6 @@ class WixFormProcessor: Response dict with status and details """ # Import here to avoid circular dependency - from .api import process_wix_form_submission # Call existing processing function result = await process_wix_form_submission(request, payload, db_session)