From b045c62cee692caa528a5783ff4ccaa8aac77e59 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Mon, 13 Oct 2025 10:51:56 +0200 Subject: [PATCH] Created hashed customers. migrated to service instead of using db logic directly --- src/alpine_bits_python/api.py | 86 ++++---- src/alpine_bits_python/customer_service.py | 178 +++++++++++++++++ src/alpine_bits_python/db.py | 62 ++++++ tests/test_customer_service.py | 216 +++++++++++++++++++++ 4 files changed, 490 insertions(+), 52 deletions(-) create mode 100644 src/alpine_bits_python/customer_service.py create mode 100644 tests/test_customer_service.py diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 273d763..b3f0615 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -15,7 +15,6 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, Response from fastapi.security import HTTPBasic, HTTPBasicCredentials from slowapi.errors import RateLimitExceeded -from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from alpine_bits_python.schemas import ReservationData @@ -28,6 +27,7 @@ from .alpinebits_server import ( ) from .auth import generate_unique_id, validate_api_key from .config_loader import load_config +from .customer_service import CustomerService from .db import Base, get_database_url from .db import Customer as DBCustomer from .db import Reservation as DBReservation @@ -222,6 +222,17 @@ async def lifespan(app: FastAPI): await conn.run_sync(Base.metadata.create_all) _LOGGER.info("Database tables checked/created at startup.") + # Hash any existing customers that don't have hashed versions yet + async with AsyncSessionLocal() as session: + customer_service = CustomerService(session) + hashed_count = await customer_service.hash_existing_customers() + if hashed_count > 0: + _LOGGER.info( + "Backfilled hashed data for %d existing customers", hashed_count + ) + else: + _LOGGER.info("All existing customers already have hashed data") + yield # Optional: Dispose engine on shutdown @@ -372,59 +383,30 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db unique_id = data.get("submissionId", generate_unique_id()) - # use database session + # Use CustomerService to handle customer creation/update with hashing + customer_service = CustomerService(db) - # Check if customer with this contact_id already exists - existing_customer = None - if contact_id: - result = await db.execute( - select(DBCustomer).where(DBCustomer.contact_id == contact_id) - ) - existing_customer = result.scalar_one_or_none() + 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, + } - if existing_customer: - # Update existing customer with new information - _LOGGER.info("Updating existing customer with contact_id: %s", contact_id) - existing_customer.given_name = first_name - existing_customer.surname = last_name - existing_customer.name_prefix = name_prefix - existing_customer.email_address = email - existing_customer.phone = phone_number - existing_customer.email_newsletter = email_newsletter - existing_customer.address_line = address_line - existing_customer.city_name = city_name - existing_customer.postal_code = postal_code - existing_customer.country_code = country_code - existing_customer.gender = gender - existing_customer.birth_date = birth_date - existing_customer.language = language - existing_customer.address_catalog = False - existing_customer.name_title = None - db_customer = existing_customer - await db.flush() - else: - # Create new customer - _LOGGER.info("Creating new customer with contact_id: %s", contact_id) - db_customer = DBCustomer( - 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, - ) - db.add(db_customer) - await db.flush() # This assigns db_customer.id without committing + # 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 diff --git a/src/alpine_bits_python/customer_service.py b/src/alpine_bits_python/customer_service.py new file mode 100644 index 0000000..03243b7 --- /dev/null +++ b/src/alpine_bits_python/customer_service.py @@ -0,0 +1,178 @@ +"""Customer service layer for handling customer and hashed customer operations.""" + +from datetime import UTC, datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .db import Customer, HashedCustomer + + +class CustomerService: + """Service for managing customers and their hashed versions. + + Automatically maintains hashed customer data whenever customers are + created or updated, ensuring data is always in sync for Meta Conversion API. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + async def create_customer(self, customer_data: dict) -> Customer: + """Create a new customer and automatically create its hashed version. + + Args: + customer_data: Dictionary containing customer fields + + Returns: + The created Customer instance (with hashed_version relationship populated) + """ + # Create the customer + customer = Customer(**customer_data) + self.session.add(customer) + await self.session.flush() # Flush to get the customer.id + + # Create hashed version + hashed_customer = customer.create_hashed_customer() + hashed_customer.created_at = datetime.now(UTC) + self.session.add(hashed_customer) + + await self.session.commit() + await self.session.refresh(customer) + + return customer + + async def update_customer( + self, customer: Customer, update_data: dict + ) -> Customer: + """Update an existing customer and sync its hashed version. + + Args: + customer: The customer to update + update_data: Dictionary of fields to update + + Returns: + The updated Customer instance + """ + # Update customer fields + for key, value in update_data.items(): + if hasattr(customer, key): + setattr(customer, key, value) + + # Update or create hashed version + result = await self.session.execute( + select(HashedCustomer).where( + HashedCustomer.customer_id == customer.id + ) + ) + hashed_customer = result.scalar_one_or_none() + + if hashed_customer: + # Update existing hashed customer + new_hashed = customer.create_hashed_customer() + hashed_customer.hashed_email = new_hashed.hashed_email + hashed_customer.hashed_phone = new_hashed.hashed_phone + hashed_customer.hashed_given_name = new_hashed.hashed_given_name + hashed_customer.hashed_surname = new_hashed.hashed_surname + hashed_customer.hashed_city = new_hashed.hashed_city + hashed_customer.hashed_postal_code = new_hashed.hashed_postal_code + hashed_customer.hashed_country_code = new_hashed.hashed_country_code + hashed_customer.hashed_gender = new_hashed.hashed_gender + hashed_customer.hashed_birth_date = new_hashed.hashed_birth_date + else: + # Create new hashed customer if it doesn't exist + hashed_customer = customer.create_hashed_customer() + hashed_customer.created_at = datetime.now(UTC) + self.session.add(hashed_customer) + + await self.session.commit() + await self.session.refresh(customer) + + return customer + + async def get_customer_by_contact_id( + self, contact_id: str + ) -> Optional[Customer]: + """Get a customer by contact_id. + + Args: + contact_id: The contact_id to search for + + Returns: + Customer instance if found, None otherwise + """ + result = await self.session.execute( + select(Customer).where(Customer.contact_id == contact_id) + ) + return result.scalar_one_or_none() + + async def get_or_create_customer(self, customer_data: dict) -> Customer: + """Get existing customer or create new one if not found. + + Uses contact_id to identify existing customers if provided. + + Args: + customer_data: Dictionary containing customer fields + (contact_id is optional) + + Returns: + Existing or newly created Customer instance + """ + contact_id = customer_data.get("contact_id") + + if contact_id: + existing = await self.get_customer_by_contact_id(contact_id) + if existing: + # Update existing customer + return await self.update_customer(existing, customer_data) + + # Create new customer (either no contact_id or customer doesn't exist) + return await self.create_customer(customer_data) + + async def get_hashed_customer( + self, customer_id: int + ) -> Optional[HashedCustomer]: + """Get the hashed version of a customer. + + Args: + customer_id: The customer ID + + Returns: + HashedCustomer instance if found, None otherwise + """ + result = await self.session.execute( + select(HashedCustomer).where( + HashedCustomer.customer_id == customer_id + ) + ) + return result.scalar_one_or_none() + + async def hash_existing_customers(self) -> int: + """Hash all existing customers that don't have a hashed version yet. + + This is useful for backfilling hashed data for customers created + before the hashing system was implemented. + + Returns: + Number of customers that were hashed + """ + # Get all customers + result = await self.session.execute(select(Customer)) + customers = result.scalars().all() + + hashed_count = 0 + for customer in customers: + # Check if this customer already has a hashed version + existing_hashed = await self.get_hashed_customer(customer.id) + if not existing_hashed: + # Create hashed version + hashed_customer = customer.create_hashed_customer() + hashed_customer.created_at = datetime.now(UTC) + self.session.add(hashed_customer) + hashed_count += 1 + + if hashed_count > 0: + await self.session.commit() + + return hashed_count diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py index d8c4b6f..0bc9a8e 100644 --- a/src/alpine_bits_python/db.py +++ b/src/alpine_bits_python/db.py @@ -1,3 +1,4 @@ +import hashlib import os from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String @@ -39,6 +40,67 @@ class Customer(Base): name_title = Column(String) # Added for XML reservations = relationship("Reservation", back_populates="customer") + @staticmethod + def _normalize_and_hash(value): + """Normalize and hash a value according to Meta Conversion API requirements.""" + if not value: + return None + # Normalize: lowercase, strip whitespace + normalized = str(value).lower().strip() + # Remove spaces for phone numbers + is_phone = normalized.startswith("+") or normalized.replace( + "-", "" + ).replace(" ", "").isdigit() + if is_phone: + chars_to_remove = [" ", "-", "(", ")"] + for char in chars_to_remove: + normalized = normalized.replace(char, "") + # SHA256 hash + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + def create_hashed_customer(self): + """Create a HashedCustomer instance from this Customer.""" + return HashedCustomer( + customer_id=self.id, + contact_id=self.contact_id, + hashed_email=self._normalize_and_hash(self.email_address), + hashed_phone=self._normalize_and_hash(self.phone), + hashed_given_name=self._normalize_and_hash(self.given_name), + hashed_surname=self._normalize_and_hash(self.surname), + hashed_city=self._normalize_and_hash(self.city_name), + hashed_postal_code=self._normalize_and_hash(self.postal_code), + hashed_country_code=self._normalize_and_hash(self.country_code), + hashed_gender=self._normalize_and_hash(self.gender), + hashed_birth_date=self._normalize_and_hash(self.birth_date), + ) + + +class HashedCustomer(Base): + """Hashed customer data for Meta Conversion API. + + Stores SHA256 hashed versions of customer PII according to Meta's requirements. + This allows sending conversion events without exposing raw customer data. + """ + + __tablename__ = "hashed_customers" + id = Column(Integer, primary_key=True) + customer_id = Column( + Integer, ForeignKey("customers.id"), unique=True, nullable=False + ) + contact_id = Column(String, unique=True) # Keep unhashed for reference + hashed_email = Column(String(64)) # SHA256 produces 64 hex chars + hashed_phone = Column(String(64)) + hashed_given_name = Column(String(64)) + hashed_surname = Column(String(64)) + hashed_city = Column(String(64)) + hashed_postal_code = Column(String(64)) + hashed_country_code = Column(String(64)) + hashed_gender = Column(String(64)) + hashed_birth_date = Column(String(64)) + created_at = Column(DateTime) + + customer = relationship("Customer", backref="hashed_version") + class Reservation(Base): __tablename__ = "reservations" diff --git a/tests/test_customer_service.py b/tests/test_customer_service.py new file mode 100644 index 0000000..de7a576 --- /dev/null +++ b/tests/test_customer_service.py @@ -0,0 +1,216 @@ +"""Tests for CustomerService functionality.""" + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from alpine_bits_python.customer_service import CustomerService +from alpine_bits_python.db import Base, Customer, HashedCustomer + + +@pytest_asyncio.fixture +async def async_session(): + """Create an async session for testing.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session_maker = async_sessionmaker(engine, expire_on_commit=False) + async with async_session_maker() as session: + yield session + + await engine.dispose() + + +@pytest.mark.asyncio +async def test_create_customer_creates_hashed_version(async_session: AsyncSession): + """Test that creating a customer automatically creates hashed version.""" + service = CustomerService(async_session) + + customer_data = { + "given_name": "John", + "surname": "Doe", + "contact_id": "test123", + "email_address": "john@example.com", + "phone": "+1234567890", + } + + customer = await service.create_customer(customer_data) + + assert customer.id is not None + assert customer.given_name == "John" + + # Check that hashed version was created + hashed = await service.get_hashed_customer(customer.id) + assert hashed is not None + assert hashed.customer_id == customer.id + assert hashed.hashed_email is not None + assert hashed.hashed_phone is not None + assert hashed.hashed_given_name is not None + assert hashed.hashed_surname is not None + + +@pytest.mark.asyncio +async def test_update_customer_updates_hashed_version(async_session: AsyncSession): + """Test that updating a customer updates the hashed version.""" + service = CustomerService(async_session) + + # Create initial customer + customer_data = { + "given_name": "John", + "surname": "Doe", + "contact_id": "test123", + "email_address": "john@example.com", + } + customer = await service.create_customer(customer_data) + + # Get initial hashed email + hashed = await service.get_hashed_customer(customer.id) + original_hashed_email = hashed.hashed_email + + # Update customer email + update_data = {"email_address": "newemail@example.com"} + updated_customer = await service.update_customer(customer, update_data) + + # Check that hashed version was updated + updated_hashed = await service.get_hashed_customer(updated_customer.id) + assert updated_hashed.hashed_email != original_hashed_email + + +@pytest.mark.asyncio +async def test_get_or_create_customer_creates_new(async_session: AsyncSession): + """Test get_or_create creates new customer when not found.""" + service = CustomerService(async_session) + + customer_data = { + "given_name": "Jane", + "surname": "Smith", + "contact_id": "new123", + "email_address": "jane@example.com", + } + + customer = await service.get_or_create_customer(customer_data) + assert customer.id is not None + assert customer.contact_id == "new123" + + # Verify hashed version exists + hashed = await service.get_hashed_customer(customer.id) + assert hashed is not None + + +@pytest.mark.asyncio +async def test_get_or_create_customer_updates_existing(async_session: AsyncSession): + """Test get_or_create updates existing customer when found.""" + service = CustomerService(async_session) + + # Create initial customer + customer_data = { + "given_name": "Jane", + "surname": "Smith", + "contact_id": "existing123", + "email_address": "jane@example.com", + } + original_customer = await service.create_customer(customer_data) + + # Try to create again with same contact_id but different data + updated_data = { + "given_name": "Janet", + "surname": "Smith", + "contact_id": "existing123", + "email_address": "janet@example.com", + } + customer = await service.get_or_create_customer(updated_data) + + # Should be same customer ID but updated data + assert customer.id == original_customer.id + assert customer.given_name == "Janet" + assert customer.email_address == "janet@example.com" + + +@pytest.mark.asyncio +async def test_hash_existing_customers_backfills(async_session: AsyncSession): + """Test that hash_existing_customers backfills missing hashed data.""" + # Create a customer directly in DB without using service + customer = Customer( + given_name="Bob", + surname="Builder", + contact_id="bob123", + email_address="bob@example.com", + phone="+9876543210", + ) + async_session.add(customer) + await async_session.commit() + await async_session.refresh(customer) + + # Verify no hashed version exists + result = await async_session.execute( + select(HashedCustomer).where(HashedCustomer.customer_id == customer.id) + ) + hashed = result.scalar_one_or_none() + assert hashed is None + + # Run backfill + service = CustomerService(async_session) + count = await service.hash_existing_customers() + + assert count == 1 + + # Verify hashed version now exists + result = await async_session.execute( + select(HashedCustomer).where(HashedCustomer.customer_id == customer.id) + ) + hashed = result.scalar_one_or_none() + assert hashed is not None + assert hashed.hashed_email is not None + + +@pytest.mark.asyncio +async def test_hash_existing_customers_skips_already_hashed( + async_session: AsyncSession, +): + """Test that hash_existing_customers skips customers already hashed.""" + service = CustomerService(async_session) + + # Create customer with service (creates hashed version) + customer_data = { + "given_name": "Alice", + "surname": "Wonder", + "contact_id": "alice123", + "email_address": "alice@example.com", + } + await service.create_customer(customer_data) + + # Run backfill - should return 0 since customer is already hashed + count = await service.hash_existing_customers() + assert count == 0 + + +@pytest.mark.asyncio +async def test_hashing_normalization(async_session: AsyncSession): + """Test that hashing properly normalizes data.""" + service = CustomerService(async_session) + + # Create customer with mixed case and spaces + customer_data = { + "given_name": " John ", + "surname": "DOE", + "contact_id": "test123", + "email_address": " John.Doe@Example.COM ", + "phone": "+1 (234) 567-8900", + } + customer = await service.create_customer(customer_data) + + hashed = await service.get_hashed_customer(customer.id) + + # Verify hashes exist (normalization should have occurred) + assert hashed.hashed_email is not None + assert hashed.hashed_phone is not None + + # Hash should be consistent for same normalized value + from alpine_bits_python.db import Customer as CustomerModel + + normalized_email_hash = CustomerModel._normalize_and_hash( + "john.doe@example.com" + ) + assert hashed.hashed_email == normalized_email_hash