# app/modules/hosting/services/hosted_site_service.py """ Hosted site service — CRUD + lifecycle management. Manages the POC → live website pipeline: draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled """ import logging import re from datetime import UTC, datetime from sqlalchemy import or_ from sqlalchemy.orm import Session, joinedload from app.modules.hosting.exceptions import ( DuplicateSlugException, HostedSiteNotFoundException, InvalidStatusTransitionException, ) from app.modules.hosting.models import HostedSite, HostedSiteStatus logger = logging.getLogger(__name__) # Valid status transitions ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = { HostedSiteStatus.DRAFT: [HostedSiteStatus.POC_READY, HostedSiteStatus.CANCELLED], HostedSiteStatus.POC_READY: [HostedSiteStatus.PROPOSAL_SENT, HostedSiteStatus.CANCELLED], HostedSiteStatus.PROPOSAL_SENT: [HostedSiteStatus.ACCEPTED, HostedSiteStatus.CANCELLED], HostedSiteStatus.ACCEPTED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED], HostedSiteStatus.LIVE: [HostedSiteStatus.SUSPENDED, HostedSiteStatus.CANCELLED], HostedSiteStatus.SUSPENDED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED], HostedSiteStatus.CANCELLED: [], } def _slugify(name: str) -> str: """Generate a URL-safe slug from a business name.""" slug = name.lower().strip() slug = re.sub(r"[^a-z0-9\s-]", "", slug) slug = re.sub(r"[\s-]+", "-", slug) return slug.strip("-")[:50] class HostedSiteService: """Service for hosted site CRUD and lifecycle operations.""" def get_by_id(self, db: Session, site_id: int) -> HostedSite: site = ( db.query(HostedSite) .options(joinedload(HostedSite.client_services)) .filter(HostedSite.id == site_id) .first() ) if not site: raise HostedSiteNotFoundException(str(site_id)) return site def get_all( self, db: Session, *, page: int = 1, per_page: int = 20, search: str | None = None, status: str | None = None, ) -> tuple[list[HostedSite], int]: query = db.query(HostedSite).options(joinedload(HostedSite.client_services)) if search: query = query.filter( or_( HostedSite.business_name.ilike(f"%{search}%"), HostedSite.contact_email.ilike(f"%{search}%"), HostedSite.live_domain.ilike(f"%{search}%"), ) ) if status: query = query.filter(HostedSite.status == status) total = query.count() sites = ( query.order_by(HostedSite.created_at.desc()) .offset((page - 1) * per_page) .limit(per_page) .all() ) return sites, total def create(self, db: Session, data: dict) -> HostedSite: """Create a hosted site with an auto-created Store on the hosting platform.""" from app.modules.tenancy.models import Platform from app.modules.tenancy.schemas.store import StoreCreate from app.modules.tenancy.services.admin_service import admin_service business_name = data["business_name"] slug = _slugify(business_name) # Find hosting platform platform = db.query(Platform).filter(Platform.code == "hosting").first() if not platform: raise ValueError("Hosting platform not found. Run init_production first.") # Create a temporary merchant-less store requires a merchant_id. # For POC sites we create a placeholder: the store is re-assigned on accept_proposal. # Use the platform's own admin store or create under a system merchant. # For now, create store via AdminService which handles defaults. store_code = slug.upper().replace("-", "_")[:50] subdomain = slug # Check for duplicate subdomain from app.modules.tenancy.models import Store existing = db.query(Store).filter(Store.subdomain == subdomain).first() if existing: raise DuplicateSlugException(subdomain) # We need a system merchant for POC sites. # Look for one or create if needed. from app.modules.tenancy.models import Merchant system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first() if not system_merchant: system_merchant = Merchant( name="HostWizard System", contact_email="system@hostwizard.lu", is_active=True, is_verified=True, ) db.add(system_merchant) db.flush() store_data = StoreCreate( merchant_id=system_merchant.id, store_code=store_code, subdomain=subdomain, name=business_name, description=f"POC website for {business_name}", platform_ids=[platform.id], ) store = admin_service.create_store(db, store_data) site = HostedSite( store_id=store.id, prospect_id=data.get("prospect_id"), status=HostedSiteStatus.DRAFT, business_name=business_name, contact_name=data.get("contact_name"), contact_email=data.get("contact_email"), contact_phone=data.get("contact_phone"), internal_notes=data.get("internal_notes"), ) db.add(site) db.flush() logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id) return site def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite: """Create a hosted site pre-filled from prospect data.""" from app.modules.prospecting.models import Prospect prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first() if not prospect: from app.modules.prospecting.exceptions import ProspectNotFoundException raise ProspectNotFoundException(str(prospect_id)) # Get primary contact info from prospect contacts contacts = prospect.contacts or [] primary_email = next((c.value for c in contacts if c.contact_type == "email"), None) primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None) contact_name = next((c.label for c in contacts if c.label), None) data = { "business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}", "contact_name": contact_name, "contact_email": primary_email, "contact_phone": primary_phone, "prospect_id": prospect.id, } return self.create(db, data) def update(self, db: Session, site_id: int, data: dict) -> HostedSite: site = self.get_by_id(db, site_id) for field in ["business_name", "contact_name", "contact_email", "contact_phone", "internal_notes"]: if field in data and data[field] is not None: setattr(site, field, data[field]) db.flush() return site def delete(self, db: Session, site_id: int) -> bool: site = self.get_by_id(db, site_id) db.delete(site) db.flush() logger.info("Deleted hosted site: %d", site_id) return True # ── Lifecycle transitions ────────────────────────────────────────────── def _transition(self, db: Session, site_id: int, target: HostedSiteStatus) -> HostedSite: """Validate and apply a status transition.""" site = self.get_by_id(db, site_id) allowed = ALLOWED_TRANSITIONS.get(site.status, []) if target not in allowed: raise InvalidStatusTransitionException(site.status.value, target.value) site.status = target db.flush() return site def mark_poc_ready(self, db: Session, site_id: int) -> HostedSite: site = self._transition(db, site_id, HostedSiteStatus.POC_READY) logger.info("Site %d marked POC ready", site_id) return site def send_proposal(self, db: Session, site_id: int, notes: str | None = None) -> HostedSite: site = self._transition(db, site_id, HostedSiteStatus.PROPOSAL_SENT) site.proposal_sent_at = datetime.now(UTC) if notes: site.proposal_notes = notes db.flush() logger.info("Proposal sent for site %d", site_id) return site def accept_proposal( self, db: Session, site_id: int, merchant_id: int | None = None ) -> HostedSite: """Accept proposal: create or link merchant, create subscription, mark converted.""" site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED) site.proposal_accepted_at = datetime.now(UTC) from app.modules.tenancy.models import Merchant, Platform if merchant_id: # Link to existing merchant merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first() if not merchant: raise ValueError(f"Merchant {merchant_id} not found") else: # Create new merchant from contact info from app.modules.tenancy.schemas.merchant import MerchantCreate from app.modules.tenancy.services.merchant_service import merchant_service email = site.contact_email or f"contact-{site.id}@hostwizard.lu" merchant_data = MerchantCreate( name=site.business_name, contact_email=email, contact_phone=site.contact_phone, owner_email=email, ) merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner( db, merchant_data ) logger.info("Created merchant %s for site %d", merchant.name, site_id) # Re-assign store to the real merchant site.store.merchant_id = merchant.id db.flush() # Create MerchantSubscription on hosting platform platform = db.query(Platform).filter(Platform.code == "hosting").first() if platform: from app.modules.billing.services.subscription_service import ( subscription_service, ) existing_sub = subscription_service.get_merchant_subscription( db, merchant.id, platform.id ) if not existing_sub: subscription_service.create_merchant_subscription( db, merchant_id=merchant.id, platform_id=platform.id, tier_code="essential", trial_days=0, ) logger.info("Created subscription for merchant %d on hosting platform", merchant.id) # Mark prospect as converted if site.prospect_id: from app.modules.prospecting.models import Prospect, ProspectStatus prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first() if prospect and prospect.status != ProspectStatus.CONVERTED: prospect.status = ProspectStatus.CONVERTED db.flush() db.flush() logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id) return site def go_live(self, db: Session, site_id: int, domain: str) -> HostedSite: """Go live: add domain to store, update site.""" site = self._transition(db, site_id, HostedSiteStatus.LIVE) site.went_live_at = datetime.now(UTC) site.live_domain = domain # Add domain to store via StoreDomainService from app.modules.tenancy.schemas.store_domain import StoreDomainCreate from app.modules.tenancy.services.store_domain_service import ( store_domain_service, ) domain_data = StoreDomainCreate(domain=domain, is_primary=True) store_domain_service.add_domain(db, site.store_id, domain_data) db.flush() logger.info("Site %d went live on %s", site_id, domain) return site def suspend(self, db: Session, site_id: int) -> HostedSite: site = self._transition(db, site_id, HostedSiteStatus.SUSPENDED) logger.info("Site %d suspended", site_id) return site def cancel(self, db: Session, site_id: int) -> HostedSite: site = self._transition(db, site_id, HostedSiteStatus.CANCELLED) logger.info("Site %d cancelled", site_id) return site hosted_site_service = HostedSiteService()