fix(hosting): require merchant or prospect for site creation
- Schema: add merchant_id/prospect_id with model_validator requiring at least one. Remove from-prospect endpoint (unified into POST /sites) - Service: rewrite create() — if merchant_id use it directly, if prospect_id auto-create merchant from prospect data. Remove system merchant hack entirely. Extract _create_merchant_from_prospect helper. - Simplify accept_proposal() — merchant already exists at creation, only creates subscription and marks prospect converted - Tests: update all create calls with merchant_id, replace from-prospect tests with prospect_id + validation tests Closes docs/proposals/hosting-site-creation-fix.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -88,12 +88,19 @@ class HostedSiteService:
|
||||
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
|
||||
"""Create a hosted site with an auto-created Store on the hosting platform.
|
||||
|
||||
Requires either merchant_id or prospect_id in data:
|
||||
- merchant_id: store created under this merchant
|
||||
- prospect_id: merchant auto-created from prospect data
|
||||
"""
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store
|
||||
from app.modules.tenancy.schemas.store import StoreCreate
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
|
||||
business_name = data["business_name"]
|
||||
merchant_id = data.get("merchant_id")
|
||||
prospect_id = data.get("prospect_id")
|
||||
slug = _slugify(business_name)
|
||||
|
||||
# Find hosting platform
|
||||
@@ -101,37 +108,25 @@ class HostedSiteService:
|
||||
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
|
||||
# Resolve merchant
|
||||
if merchant_id:
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise ValueError(f"Merchant {merchant_id} not found")
|
||||
elif prospect_id:
|
||||
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
|
||||
else:
|
||||
raise ValueError("Either merchant_id or prospect_id is required")
|
||||
|
||||
# Check for duplicate subdomain
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
subdomain = slug
|
||||
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_code = slug.upper().replace("-", "_")[:50]
|
||||
store_data = StoreCreate(
|
||||
merchant_id=system_merchant.id,
|
||||
merchant_id=merchant.id,
|
||||
store_code=store_code,
|
||||
subdomain=subdomain,
|
||||
name=business_name,
|
||||
@@ -142,7 +137,7 @@ class HostedSiteService:
|
||||
|
||||
site = HostedSite(
|
||||
store_id=store.id,
|
||||
prospect_id=data.get("prospect_id"),
|
||||
prospect_id=prospect_id,
|
||||
status=HostedSiteStatus.DRAFT,
|
||||
business_name=business_name,
|
||||
contact_name=data.get("contact_name"),
|
||||
@@ -153,12 +148,14 @@ class HostedSiteService:
|
||||
db.add(site)
|
||||
db.flush()
|
||||
|
||||
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
|
||||
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
|
||||
return site
|
||||
|
||||
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
|
||||
"""Create a hosted site pre-filled from prospect data."""
|
||||
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
|
||||
"""Create a merchant from prospect data."""
|
||||
from app.modules.prospecting.models import Prospect
|
||||
from app.modules.tenancy.schemas.merchant import MerchantCreate
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
|
||||
if not prospect:
|
||||
@@ -166,20 +163,29 @@ class HostedSiteService:
|
||||
|
||||
raise ProspectNotFoundException(str(prospect_id))
|
||||
|
||||
# Get primary contact info from prospect contacts
|
||||
# Get contact info: prefer form data, fall back to 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)
|
||||
email = (
|
||||
data.get("contact_email")
|
||||
or next((c.value for c in contacts if c.contact_type == "email"), None)
|
||||
or f"contact-{prospect_id}@hostwizard.lu"
|
||||
)
|
||||
phone = data.get("contact_phone") or next(
|
||||
(c.value for c in contacts if c.contact_type == "phone"), None
|
||||
)
|
||||
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
|
||||
|
||||
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)
|
||||
merchant_data = MerchantCreate(
|
||||
name=business_name,
|
||||
contact_email=email,
|
||||
contact_phone=phone,
|
||||
owner_email=email,
|
||||
)
|
||||
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
|
||||
db, merchant_data
|
||||
)
|
||||
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
|
||||
return merchant
|
||||
|
||||
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
|
||||
site = self.get_by_id(db, site_id)
|
||||
@@ -227,37 +233,25 @@ class HostedSiteService:
|
||||
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."""
|
||||
"""Accept proposal: create subscription, mark prospect converted.
|
||||
|
||||
The merchant already exists (assigned at site creation time).
|
||||
Optionally pass merchant_id to reassign to a different merchant.
|
||||
"""
|
||||
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
|
||||
site.proposal_accepted_at = datetime.now(UTC)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Platform
|
||||
|
||||
# Use provided merchant_id to reassign, or keep existing store merchant
|
||||
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")
|
||||
site.store.merchant_id = merchant.id
|
||||
db.flush()
|
||||
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()
|
||||
merchant = site.store.merchant
|
||||
|
||||
# Create MerchantSubscription on hosting platform
|
||||
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
||||
@@ -286,7 +280,6 @@ class HostedSiteService:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user