diff --git a/alembic.ini b/alembic.ini
index 35e367b1..32336f7c 100644
--- a/alembic.ini
+++ b/alembic.ini
@@ -3,7 +3,7 @@
script_location = alembic
prepend_sys_path = .
version_path_separator = space
-version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/tenancy/migrations/versions
+version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/hosting/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/prospecting/migrations/versions app/modules/tenancy/migrations/versions
# This will be overridden by alembic\env.py using settings.database_url
sqlalchemy.url =
# for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db
diff --git a/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py b/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py
index 57046779..a5c78209 100644
--- a/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py
+++ b/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py
@@ -1,7 +1,7 @@
"""add title_translations and content_translations to content_pages
Revision ID: cms_002
-Revises: cms_001
+Revises: z_unique_subdomain_domain
Create Date: 2026-03-02
"""
@@ -10,7 +10,7 @@ import sqlalchemy as sa
from alembic import op
revision = "cms_002"
-down_revision = "cms_001"
+down_revision = "z_unique_subdomain_domain"
branch_labels = None
depends_on = None
diff --git a/app/modules/hosting/__init__.py b/app/modules/hosting/__init__.py
new file mode 100644
index 00000000..e4b43937
--- /dev/null
+++ b/app/modules/hosting/__init__.py
@@ -0,0 +1,36 @@
+# app/modules/hosting/__init__.py
+"""
+Hosting Module - Web hosting, domains, email, and website building.
+
+This is a self-contained module providing:
+- POC website lifecycle management (draft → live pipeline)
+- Client service tracking (domains, email, SSL, hosting, maintenance)
+- Integration with prospecting module for prospect → merchant conversion
+- Dashboard with KPIs, status charts, and renewal alerts
+
+Module Structure:
+- models/ - Database models (hosted_site, client_service)
+- services/ - Business logic (lifecycle, service management, stats)
+- schemas/ - Pydantic DTOs
+- routes/ - API and page routes (admin + public POC viewer)
+- tasks/ - Celery background tasks for expiry checks
+- exceptions.py - Module-specific exceptions
+"""
+
+
+def __getattr__(name: str):
+ """Lazy import module components to avoid circular imports."""
+ if name == "hosting_module":
+ from app.modules.hosting.definition import hosting_module
+
+ return hosting_module
+ if name == "get_hosting_module_with_routers":
+ from app.modules.hosting.definition import (
+ get_hosting_module_with_routers,
+ )
+
+ return get_hosting_module_with_routers
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+__all__ = ["hosting_module", "get_hosting_module_with_routers"]
diff --git a/app/modules/hosting/config.py b/app/modules/hosting/config.py
new file mode 100644
index 00000000..409b1bea
--- /dev/null
+++ b/app/modules/hosting/config.py
@@ -0,0 +1,31 @@
+# app/modules/hosting/config.py
+"""
+Module configuration.
+
+Environment-based configuration using Pydantic Settings.
+Settings are loaded from environment variables with HOSTING_ prefix.
+
+Example:
+ HOSTING_DEFAULT_BILLING_PERIOD=annual
+"""
+from pydantic_settings import BaseSettings
+
+
+class ModuleConfig(BaseSettings):
+ """Configuration for hosting module."""
+
+ # Default billing period for new services
+ default_billing_period: str = "monthly"
+
+ # Days before expiry to trigger renewal alerts
+ renewal_alert_days: int = 30
+
+ # Default currency for pricing
+ default_currency: str = "EUR"
+
+ model_config = {"env_prefix": "HOSTING_"}
+
+
+# Export for auto-discovery
+config_class = ModuleConfig
+config = ModuleConfig()
diff --git a/app/modules/hosting/definition.py b/app/modules/hosting/definition.py
new file mode 100644
index 00000000..1164e040
--- /dev/null
+++ b/app/modules/hosting/definition.py
@@ -0,0 +1,132 @@
+# app/modules/hosting/definition.py
+"""
+Hosting module definition.
+
+Web hosting, domains, email, and website building for Luxembourg businesses.
+Manages POC → live website pipeline and operational service tracking.
+Admin-only module for superadmin and platform admin users.
+"""
+
+from app.modules.base import (
+ MenuItemDefinition,
+ MenuSectionDefinition,
+ ModuleDefinition,
+ PermissionDefinition,
+)
+from app.modules.enums import FrontendType
+
+
+def _get_admin_api_router():
+ """Lazy import of admin API router to avoid circular imports."""
+ from app.modules.hosting.routes.api.admin import router
+
+ return router
+
+
+def _get_admin_page_router():
+ """Lazy import of admin page router to avoid circular imports."""
+ from app.modules.hosting.routes.pages.admin import router
+
+ return router
+
+
+def _get_public_page_router():
+ """Lazy import of public page router to avoid circular imports."""
+ from app.modules.hosting.routes.pages.public import router
+
+ return router
+
+
+hosting_module = ModuleDefinition(
+ code="hosting",
+ name="Hosting",
+ description="Web hosting, domains, email, and website building for Luxembourg businesses.",
+ version="1.0.0",
+ is_core=False,
+ is_self_contained=True,
+ requires=["prospecting"],
+ permissions=[
+ PermissionDefinition(
+ id="hosting.view",
+ label_key="hosting.permissions.view",
+ description_key="hosting.permissions.view_desc",
+ category="hosting",
+ ),
+ PermissionDefinition(
+ id="hosting.manage",
+ label_key="hosting.permissions.manage",
+ description_key="hosting.permissions.manage_desc",
+ category="hosting",
+ ),
+ ],
+ features=[
+ "hosting",
+ "domains",
+ "email",
+ "ssl",
+ "poc_sites",
+ ],
+ menu_items={
+ FrontendType.ADMIN: [
+ "hosting-dashboard",
+ "hosting-sites",
+ "hosting-clients",
+ ],
+ },
+ menus={
+ FrontendType.ADMIN: [
+ MenuSectionDefinition(
+ id="hosting",
+ label_key="hosting.menu.hosting",
+ icon="globe",
+ order=65,
+ items=[
+ MenuItemDefinition(
+ id="hosting-dashboard",
+ label_key="hosting.menu.dashboard",
+ icon="chart-bar",
+ route="/admin/hosting",
+ order=1,
+ ),
+ MenuItemDefinition(
+ id="hosting-sites",
+ label_key="hosting.menu.sites",
+ icon="globe",
+ route="/admin/hosting/sites",
+ order=5,
+ ),
+ MenuItemDefinition(
+ id="hosting-clients",
+ label_key="hosting.menu.clients",
+ icon="server",
+ route="/admin/hosting/clients",
+ order=10,
+ ),
+ ],
+ ),
+ ],
+ },
+ migrations_path="migrations",
+ services_path="app.modules.hosting.services",
+ models_path="app.modules.hosting.models",
+ schemas_path="app.modules.hosting.schemas",
+ exceptions_path="app.modules.hosting.exceptions",
+ templates_path="templates",
+ locales_path="locales",
+ tasks_path="app.modules.hosting.tasks",
+)
+
+
+def get_hosting_module_with_routers() -> ModuleDefinition:
+ """
+ Get hosting module with routers attached.
+
+ Attaches routers lazily to avoid circular imports during module initialization.
+ """
+ hosting_module.admin_api_router = _get_admin_api_router()
+ hosting_module.admin_page_router = _get_admin_page_router()
+ hosting_module.public_page_router = _get_public_page_router()
+ return hosting_module
+
+
+__all__ = ["hosting_module", "get_hosting_module_with_routers"]
diff --git a/app/modules/hosting/exceptions.py b/app/modules/hosting/exceptions.py
new file mode 100644
index 00000000..f2ba3a15
--- /dev/null
+++ b/app/modules/hosting/exceptions.py
@@ -0,0 +1,57 @@
+# app/modules/hosting/exceptions.py
+"""
+Hosting module exceptions.
+"""
+
+from app.exceptions.base import (
+ BusinessLogicException,
+ ResourceNotFoundException,
+)
+
+
+class HostedSiteNotFoundException(ResourceNotFoundException): # noqa: MOD025
+ """Raised when a hosted site is not found."""
+
+ def __init__(self, identifier: str):
+ super().__init__(
+ resource_type="HostedSite",
+ identifier=identifier,
+ )
+
+
+class ClientServiceNotFoundException(ResourceNotFoundException): # noqa: MOD025
+ """Raised when a client service is not found."""
+
+ def __init__(self, identifier: str):
+ super().__init__(
+ resource_type="ClientService",
+ identifier=identifier,
+ )
+
+
+class DuplicateSlugException(BusinessLogicException): # noqa: MOD025
+ """Raised when trying to create a site with a slug that already exists."""
+
+ def __init__(self, slug: str):
+ super().__init__(
+ message=f"A hosted site with slug '{slug}' already exists",
+ error_code="DUPLICATE_SLUG",
+ )
+
+
+class InvalidStatusTransitionException(BusinessLogicException): # noqa: MOD025
+ """Raised when attempting an invalid status transition."""
+
+ def __init__(self, current_status: str, target_status: str):
+ super().__init__(
+ message=f"Cannot transition from '{current_status}' to '{target_status}'",
+ error_code="INVALID_STATUS_TRANSITION",
+ )
+
+
+__all__ = [
+ "HostedSiteNotFoundException",
+ "ClientServiceNotFoundException",
+ "DuplicateSlugException",
+ "InvalidStatusTransitionException",
+]
diff --git a/app/modules/hosting/locales/de.json b/app/modules/hosting/locales/de.json
new file mode 100644
index 00000000..15056fb3
--- /dev/null
+++ b/app/modules/hosting/locales/de.json
@@ -0,0 +1,22 @@
+{
+ "hosting": {
+ "page_title": "Hosting",
+ "dashboard_title": "Hosting Dashboard",
+ "sites": "Webseiten",
+ "clients": "Kundendienste",
+ "loading": "Laden...",
+ "error_loading": "Fehler beim Laden der Daten"
+ },
+ "permissions": {
+ "view": "Hosting anzeigen",
+ "view_desc": "Zugriff auf gehostete Webseiten, Dienste und Statistiken",
+ "manage": "Hosting verwalten",
+ "manage_desc": "Gehostete Webseiten und Dienste erstellen, bearbeiten und verwalten"
+ },
+ "menu": {
+ "hosting": "Hosting",
+ "dashboard": "Dashboard",
+ "sites": "Webseiten",
+ "clients": "Kundendienste"
+ }
+}
diff --git a/app/modules/hosting/locales/en.json b/app/modules/hosting/locales/en.json
new file mode 100644
index 00000000..d6813dbe
--- /dev/null
+++ b/app/modules/hosting/locales/en.json
@@ -0,0 +1,22 @@
+{
+ "hosting": {
+ "page_title": "Hosting",
+ "dashboard_title": "Hosting Dashboard",
+ "sites": "Sites",
+ "clients": "Client Services",
+ "loading": "Loading...",
+ "error_loading": "Failed to load data"
+ },
+ "permissions": {
+ "view": "View Hosting",
+ "view_desc": "View hosted sites, services, and statistics",
+ "manage": "Manage Hosting",
+ "manage_desc": "Create, edit, and manage hosted sites and services"
+ },
+ "menu": {
+ "hosting": "Hosting",
+ "dashboard": "Dashboard",
+ "sites": "Sites",
+ "clients": "Client Services"
+ }
+}
diff --git a/app/modules/hosting/locales/fr.json b/app/modules/hosting/locales/fr.json
new file mode 100644
index 00000000..3c1fdb94
--- /dev/null
+++ b/app/modules/hosting/locales/fr.json
@@ -0,0 +1,22 @@
+{
+ "hosting": {
+ "page_title": "Hébergement",
+ "dashboard_title": "Tableau de bord Hébergement",
+ "sites": "Sites",
+ "clients": "Services clients",
+ "loading": "Chargement...",
+ "error_loading": "Erreur lors du chargement des données"
+ },
+ "permissions": {
+ "view": "Voir l'hébergement",
+ "view_desc": "Voir les sites hébergés, services et statistiques",
+ "manage": "Gérer l'hébergement",
+ "manage_desc": "Créer, modifier et gérer les sites hébergés et services"
+ },
+ "menu": {
+ "hosting": "Hébergement",
+ "dashboard": "Tableau de bord",
+ "sites": "Sites",
+ "clients": "Services clients"
+ }
+}
diff --git a/app/modules/hosting/locales/lb.json b/app/modules/hosting/locales/lb.json
new file mode 100644
index 00000000..3efbae1f
--- /dev/null
+++ b/app/modules/hosting/locales/lb.json
@@ -0,0 +1,22 @@
+{
+ "hosting": {
+ "page_title": "Hosting",
+ "dashboard_title": "Hosting Dashboard",
+ "sites": "Websäiten",
+ "clients": "Client Servicer",
+ "loading": "Lueden...",
+ "error_loading": "Feeler beim Luede vun den Donnéeën"
+ },
+ "permissions": {
+ "view": "Hosting kucken",
+ "view_desc": "Zougang zu gehosten Websäiten, Servicer a Statistiken",
+ "manage": "Hosting geréieren",
+ "manage_desc": "Gehost Websäiten a Servicer erstellen, änneren a geréieren"
+ },
+ "menu": {
+ "hosting": "Hosting",
+ "dashboard": "Dashboard",
+ "sites": "Websäiten",
+ "clients": "Client Servicer"
+ }
+}
diff --git a/app/modules/hosting/migrations/__init__.py b/app/modules/hosting/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/app/modules/hosting/migrations/versions/__init__.py b/app/modules/hosting/migrations/versions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/app/modules/hosting/migrations/versions/hosting_001_initial.py b/app/modules/hosting/migrations/versions/hosting_001_initial.py
new file mode 100644
index 00000000..753fa148
--- /dev/null
+++ b/app/modules/hosting/migrations/versions/hosting_001_initial.py
@@ -0,0 +1,125 @@
+"""hosting: initial tables for hosted sites and client services
+
+Revision ID: hosting_001
+Revises: prospecting_001
+Create Date: 2026-03-03
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "hosting_001"
+down_revision = "prospecting_001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # --- hosted_sites ---
+ op.create_table(
+ "hosted_sites",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column(
+ "store_id",
+ sa.Integer(),
+ sa.ForeignKey("stores.id", ondelete="CASCADE"),
+ nullable=False,
+ unique=True,
+ ),
+ sa.Column(
+ "prospect_id",
+ sa.Integer(),
+ sa.ForeignKey("prospects.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column(
+ "status",
+ sa.String(20),
+ nullable=False,
+ server_default="draft",
+ ),
+ sa.Column("business_name", sa.String(255), nullable=False),
+ sa.Column("contact_name", sa.String(255), nullable=True),
+ sa.Column("contact_email", sa.String(255), nullable=True),
+ sa.Column("contact_phone", sa.String(50), nullable=True),
+ sa.Column("proposal_sent_at", sa.DateTime(), nullable=True),
+ sa.Column("proposal_accepted_at", sa.DateTime(), nullable=True),
+ sa.Column("went_live_at", sa.DateTime(), nullable=True),
+ sa.Column("proposal_notes", sa.Text(), nullable=True),
+ sa.Column("live_domain", sa.String(255), nullable=True, unique=True),
+ sa.Column("internal_notes", sa.Text(), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+ op.create_index("ix_hosted_sites_status", "hosted_sites", ["status"])
+ op.create_index("ix_hosted_sites_prospect_id", "hosted_sites", ["prospect_id"])
+
+ # --- client_services ---
+ op.create_table(
+ "client_services",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column(
+ "hosted_site_id",
+ sa.Integer(),
+ sa.ForeignKey("hosted_sites.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column("service_type", sa.String(30), nullable=False),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column(
+ "status",
+ sa.String(20),
+ nullable=False,
+ server_default="pending",
+ ),
+ sa.Column("billing_period", sa.String(20), nullable=True),
+ sa.Column("price_cents", sa.Integer(), nullable=True),
+ sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
+ sa.Column(
+ "addon_product_id",
+ sa.Integer(),
+ sa.ForeignKey("addon_products.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column("domain_name", sa.String(255), nullable=True),
+ sa.Column("registrar", sa.String(100), nullable=True),
+ sa.Column("mailbox_count", sa.Integer(), nullable=True),
+ sa.Column("expires_at", sa.DateTime(), nullable=True),
+ sa.Column("period_start", sa.DateTime(), nullable=True),
+ sa.Column("period_end", sa.DateTime(), nullable=True),
+ sa.Column("auto_renew", sa.Boolean(), nullable=False, server_default="true"),
+ sa.Column("notes", sa.Text(), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+ op.create_index("ix_client_services_service_type", "client_services", ["service_type"])
+ op.create_index("ix_client_services_status", "client_services", ["status"])
+ op.create_index("ix_client_services_expires_at", "client_services", ["expires_at"])
+
+
+def downgrade() -> None:
+ op.drop_table("client_services")
+ op.drop_table("hosted_sites")
diff --git a/app/modules/hosting/models/__init__.py b/app/modules/hosting/models/__init__.py
new file mode 100644
index 00000000..2a8c4541
--- /dev/null
+++ b/app/modules/hosting/models/__init__.py
@@ -0,0 +1,20 @@
+# app/modules/hosting/models/__init__.py
+from app.modules.hosting.models.client_service import (
+ BillingPeriod,
+ ClientService,
+ ClientServiceStatus,
+ ServiceType,
+)
+from app.modules.hosting.models.hosted_site import (
+ HostedSite,
+ HostedSiteStatus,
+)
+
+__all__ = [
+ "HostedSite",
+ "HostedSiteStatus",
+ "ClientService",
+ "ServiceType",
+ "ClientServiceStatus",
+ "BillingPeriod",
+]
diff --git a/app/modules/hosting/models/client_service.py b/app/modules/hosting/models/client_service.py
new file mode 100644
index 00000000..366360ce
--- /dev/null
+++ b/app/modules/hosting/models/client_service.py
@@ -0,0 +1,97 @@
+# app/modules/hosting/models/client_service.py
+"""
+ClientService model - operational tracking for services.
+
+Complements billing StoreAddOn with operational details like domain expiry,
+registrar info, and mailbox counts.
+"""
+
+import enum
+
+from sqlalchemy import (
+ Boolean,
+ Column,
+ DateTime,
+ Enum,
+ ForeignKey,
+ Integer,
+ String,
+ Text,
+)
+from sqlalchemy.orm import relationship
+
+from app.core.database import Base
+from models.database.base import TimestampMixin
+
+
+class ServiceType(str, enum.Enum):
+ DOMAIN = "domain"
+ EMAIL = "email"
+ SSL = "ssl"
+ HOSTING = "hosting"
+ WEBSITE_MAINTENANCE = "website_maintenance"
+
+
+class ClientServiceStatus(str, enum.Enum):
+ PENDING = "pending"
+ ACTIVE = "active"
+ SUSPENDED = "suspended"
+ EXPIRED = "expired"
+ CANCELLED = "cancelled"
+
+
+class BillingPeriod(str, enum.Enum):
+ MONTHLY = "monthly"
+ ANNUAL = "annual"
+ ONE_TIME = "one_time"
+
+
+class ClientService(Base, TimestampMixin):
+ """Represents an operational service for a hosted site."""
+
+ __tablename__ = "client_services"
+
+ id = Column(Integer, primary_key=True, index=True)
+ hosted_site_id = Column(
+ Integer,
+ ForeignKey("hosted_sites.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ service_type = Column(Enum(ServiceType), nullable=False)
+ name = Column(String(255), nullable=False)
+ description = Column(Text, nullable=True)
+ status = Column(
+ Enum(ClientServiceStatus),
+ nullable=False,
+ default=ClientServiceStatus.PENDING,
+ )
+
+ # Billing
+ billing_period = Column(Enum(BillingPeriod), nullable=True)
+ price_cents = Column(Integer, nullable=True)
+ currency = Column(String(3), nullable=False, default="EUR")
+ addon_product_id = Column(
+ Integer,
+ ForeignKey("addon_products.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+
+ # Domain-specific
+ domain_name = Column(String(255), nullable=True)
+ registrar = Column(String(100), nullable=True)
+
+ # Email-specific
+ mailbox_count = Column(Integer, nullable=True)
+
+ # Expiry and renewal
+ expires_at = Column(DateTime, nullable=True)
+ period_start = Column(DateTime, nullable=True)
+ period_end = Column(DateTime, nullable=True)
+ auto_renew = Column(Boolean, nullable=False, default=True)
+
+ # Notes
+ notes = Column(Text, nullable=True)
+
+ # Relationships
+ hosted_site = relationship("HostedSite", back_populates="client_services")
diff --git a/app/modules/hosting/models/hosted_site.py b/app/modules/hosting/models/hosted_site.py
new file mode 100644
index 00000000..3f5e3767
--- /dev/null
+++ b/app/modules/hosting/models/hosted_site.py
@@ -0,0 +1,81 @@
+# app/modules/hosting/models/hosted_site.py
+"""
+HostedSite model - links a Store to a Prospect and tracks the POC → live lifecycle.
+
+Lifecycle: draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled
+"""
+
+import enum
+
+from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import relationship
+
+from app.core.database import Base
+from models.database.base import TimestampMixin
+
+
+class HostedSiteStatus(str, enum.Enum):
+ DRAFT = "draft"
+ POC_READY = "poc_ready"
+ PROPOSAL_SENT = "proposal_sent"
+ ACCEPTED = "accepted"
+ LIVE = "live"
+ SUSPENDED = "suspended"
+ CANCELLED = "cancelled"
+
+
+class HostedSite(Base, TimestampMixin):
+ """Represents a hosted website linking a Store to a Prospect."""
+
+ __tablename__ = "hosted_sites"
+
+ id = Column(Integer, primary_key=True, index=True)
+ store_id = Column(
+ Integer,
+ ForeignKey("stores.id", ondelete="CASCADE"),
+ nullable=False,
+ unique=True,
+ )
+ prospect_id = Column(
+ Integer,
+ ForeignKey("prospects.id", ondelete="SET NULL"),
+ nullable=True,
+ )
+ status = Column(
+ Enum(HostedSiteStatus),
+ nullable=False,
+ default=HostedSiteStatus.DRAFT,
+ )
+
+ # Denormalized for dashboard display
+ business_name = Column(String(255), nullable=False)
+ contact_name = Column(String(255), nullable=True)
+ contact_email = Column(String(255), nullable=True)
+ contact_phone = Column(String(50), nullable=True)
+
+ # Lifecycle timestamps
+ proposal_sent_at = Column(DateTime, nullable=True)
+ proposal_accepted_at = Column(DateTime, nullable=True)
+ went_live_at = Column(DateTime, nullable=True)
+
+ # Proposal
+ proposal_notes = Column(Text, nullable=True)
+
+ # Denormalized from StoreDomain
+ live_domain = Column(String(255), nullable=True, unique=True)
+
+ # Internal notes
+ internal_notes = Column(Text, nullable=True)
+
+ # Relationships
+ store = relationship("Store", backref="hosted_site", uselist=False)
+ prospect = relationship("Prospect", backref="hosted_sites")
+ client_services = relationship(
+ "ClientService",
+ back_populates="hosted_site",
+ cascade="all, delete-orphan",
+ )
+
+ @property
+ def display_name(self) -> str:
+ return self.business_name or f"Site #{self.id}"
diff --git a/app/modules/hosting/routes/__init__.py b/app/modules/hosting/routes/__init__.py
new file mode 100644
index 00000000..11187237
--- /dev/null
+++ b/app/modules/hosting/routes/__init__.py
@@ -0,0 +1 @@
+# app/modules/hosting/routes/__init__.py
diff --git a/app/modules/hosting/routes/api/__init__.py b/app/modules/hosting/routes/api/__init__.py
new file mode 100644
index 00000000..49d677da
--- /dev/null
+++ b/app/modules/hosting/routes/api/__init__.py
@@ -0,0 +1 @@
+# app/modules/hosting/routes/api/__init__.py
diff --git a/app/modules/hosting/routes/api/admin.py b/app/modules/hosting/routes/api/admin.py
new file mode 100644
index 00000000..07d93fa9
--- /dev/null
+++ b/app/modules/hosting/routes/api/admin.py
@@ -0,0 +1,21 @@
+# app/modules/hosting/routes/api/admin.py
+"""
+Hosting module admin API routes.
+
+Aggregates all admin hosting routes:
+- /sites/* - Hosted site CRUD and lifecycle
+- /sites/{id}/services/* - Client service management
+- /stats/* - Dashboard statistics
+"""
+
+from fastapi import APIRouter
+
+from .admin_services import router as admin_services_router
+from .admin_sites import router as admin_sites_router
+from .admin_stats import router as admin_stats_router
+
+router = APIRouter()
+
+router.include_router(admin_sites_router, tags=["hosting-sites"])
+router.include_router(admin_services_router, tags=["hosting-services"])
+router.include_router(admin_stats_router, tags=["hosting-stats"])
diff --git a/app/modules/hosting/routes/api/admin_services.py b/app/modules/hosting/routes/api/admin_services.py
index c920eddc..fcb32593 100644
--- a/app/modules/hosting/routes/api/admin_services.py
+++ b/app/modules/hosting/routes/api/admin_services.py
@@ -12,6 +12,7 @@ from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.hosting.schemas.client_service import (
ClientServiceCreate,
+ ClientServiceDeleteResponse,
ClientServiceResponse,
ClientServiceUpdate,
)
@@ -59,7 +60,7 @@ def update_service(
return service
-@router.delete("/{service_id}")
+@router.delete("/{service_id}", response_model=ClientServiceDeleteResponse)
def delete_service(
site_id: int = Path(...),
service_id: int = Path(...),
@@ -69,4 +70,4 @@ def delete_service(
"""Delete a client service."""
client_service_service.delete(db, service_id)
db.commit()
- return {"message": "Service deleted"} # noqa: API001
+ return ClientServiceDeleteResponse(message="Service deleted")
diff --git a/app/modules/hosting/routes/api/admin_sites.py b/app/modules/hosting/routes/api/admin_sites.py
new file mode 100644
index 00000000..e08ba1fa
--- /dev/null
+++ b/app/modules/hosting/routes/api/admin_sites.py
@@ -0,0 +1,210 @@
+# app/modules/hosting/routes/api/admin_sites.py
+"""
+Admin API routes for hosted site management.
+"""
+
+import logging
+from math import ceil
+
+from fastapi import APIRouter, Depends, Path, Query
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_admin_api
+from app.core.database import get_db
+from app.modules.hosting.schemas.hosted_site import (
+ AcceptProposalRequest,
+ GoLiveRequest,
+ HostedSiteCreate,
+ HostedSiteDeleteResponse,
+ HostedSiteDetailResponse,
+ HostedSiteListResponse,
+ HostedSiteResponse,
+ HostedSiteUpdate,
+ SendProposalRequest,
+)
+from app.modules.hosting.services.hosted_site_service import hosted_site_service
+from app.modules.tenancy.schemas.auth import UserContext
+
+router = APIRouter(prefix="/sites")
+logger = logging.getLogger(__name__)
+
+
+def _to_response(site) -> HostedSiteResponse:
+ """Convert a hosted site model to response schema."""
+ return HostedSiteResponse(
+ id=site.id,
+ store_id=site.store_id,
+ prospect_id=site.prospect_id,
+ status=site.status.value if hasattr(site.status, "value") else str(site.status),
+ business_name=site.business_name,
+ contact_name=site.contact_name,
+ contact_email=site.contact_email,
+ contact_phone=site.contact_phone,
+ proposal_sent_at=site.proposal_sent_at,
+ proposal_accepted_at=site.proposal_accepted_at,
+ went_live_at=site.went_live_at,
+ proposal_notes=site.proposal_notes,
+ live_domain=site.live_domain,
+ internal_notes=site.internal_notes,
+ created_at=site.created_at,
+ updated_at=site.updated_at,
+ )
+
+
+@router.get("", response_model=HostedSiteListResponse)
+def list_sites(
+ page: int = Query(1, ge=1),
+ per_page: int = Query(20, ge=1, le=100),
+ search: str | None = Query(None),
+ status: str | None = Query(None),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """List hosted sites with filters and pagination."""
+ sites, total = hosted_site_service.get_all(
+ db, page=page, per_page=per_page, search=search, status=status,
+ )
+ return HostedSiteListResponse(
+ items=[_to_response(s) for s in sites],
+ total=total,
+ page=page,
+ per_page=per_page,
+ pages=ceil(total / per_page) if per_page else 0,
+ )
+
+
+@router.get("/{site_id}", response_model=HostedSiteDetailResponse)
+def get_site(
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Get full hosted site detail with services."""
+ site = hosted_site_service.get_by_id(db, site_id)
+ return site
+
+
+@router.post("", response_model=HostedSiteResponse)
+def create_site(
+ data: HostedSiteCreate,
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Create a new hosted site (auto-creates Store)."""
+ site = hosted_site_service.create(db, data.model_dump(exclude_none=True))
+ db.commit()
+ return _to_response(site)
+
+
+@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
+def create_from_prospect(
+ prospect_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Create a hosted site pre-filled from prospect data."""
+ site = hosted_site_service.create_from_prospect(db, prospect_id)
+ db.commit()
+ return _to_response(site)
+
+
+@router.put("/{site_id}", response_model=HostedSiteResponse)
+def update_site(
+ data: HostedSiteUpdate,
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Update a hosted site."""
+ site = hosted_site_service.update(db, site_id, data.model_dump(exclude_none=True))
+ db.commit()
+ return _to_response(site)
+
+
+@router.delete("/{site_id}", response_model=HostedSiteDeleteResponse)
+def delete_site(
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Delete a hosted site."""
+ hosted_site_service.delete(db, site_id)
+ db.commit()
+ return HostedSiteDeleteResponse(message="Hosted site deleted")
+
+
+# ── Lifecycle endpoints ────────────────────────────────────────────────
+
+@router.post("/{site_id}/mark-poc-ready", response_model=HostedSiteResponse)
+def mark_poc_ready(
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Mark site as POC ready."""
+ site = hosted_site_service.mark_poc_ready(db, site_id)
+ db.commit()
+ return _to_response(site)
+
+
+@router.post("/{site_id}/send-proposal", response_model=HostedSiteResponse)
+def send_proposal(
+ data: SendProposalRequest,
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Send proposal to prospect."""
+ site = hosted_site_service.send_proposal(db, site_id, notes=data.notes)
+ db.commit()
+ return _to_response(site)
+
+
+@router.post("/{site_id}/accept", response_model=HostedSiteResponse)
+def accept_proposal(
+ data: AcceptProposalRequest,
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Accept proposal: create/link merchant, create subscription."""
+ site = hosted_site_service.accept_proposal(db, site_id, merchant_id=data.merchant_id)
+ db.commit()
+ return _to_response(site)
+
+
+@router.post("/{site_id}/go-live", response_model=HostedSiteResponse)
+def go_live(
+ data: GoLiveRequest,
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Go live with a domain."""
+ site = hosted_site_service.go_live(db, site_id, domain=data.domain)
+ db.commit()
+ return _to_response(site)
+
+
+@router.post("/{site_id}/suspend", response_model=HostedSiteResponse)
+def suspend_site(
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Suspend a site."""
+ site = hosted_site_service.suspend(db, site_id)
+ db.commit()
+ return _to_response(site)
+
+
+@router.post("/{site_id}/cancel", response_model=HostedSiteResponse)
+def cancel_site(
+ site_id: int = Path(...),
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Cancel a site."""
+ site = hosted_site_service.cancel(db, site_id)
+ db.commit()
+ return _to_response(site)
diff --git a/app/modules/hosting/routes/api/admin_stats.py b/app/modules/hosting/routes/api/admin_stats.py
new file mode 100644
index 00000000..8fd4a096
--- /dev/null
+++ b/app/modules/hosting/routes/api/admin_stats.py
@@ -0,0 +1,26 @@
+# app/modules/hosting/routes/api/admin_stats.py
+"""
+Admin API routes for hosting dashboard statistics.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_current_admin_api
+from app.core.database import get_db
+from app.modules.hosting.services.stats_service import stats_service
+from app.modules.tenancy.schemas.auth import UserContext
+
+router = APIRouter(prefix="/stats")
+logger = logging.getLogger(__name__)
+
+
+@router.get("/dashboard")
+def get_dashboard_stats(
+ db: Session = Depends(get_db),
+ current_admin: UserContext = Depends(get_current_admin_api),
+):
+ """Get dashboard statistics for the hosting module."""
+ return stats_service.get_dashboard_stats(db)
diff --git a/app/modules/hosting/routes/pages/__init__.py b/app/modules/hosting/routes/pages/__init__.py
new file mode 100644
index 00000000..78ae794c
--- /dev/null
+++ b/app/modules/hosting/routes/pages/__init__.py
@@ -0,0 +1 @@
+# app/modules/hosting/routes/pages/__init__.py
diff --git a/app/modules/hosting/routes/pages/admin.py b/app/modules/hosting/routes/pages/admin.py
new file mode 100644
index 00000000..894882c1
--- /dev/null
+++ b/app/modules/hosting/routes/pages/admin.py
@@ -0,0 +1,95 @@
+# app/modules/hosting/routes/pages/admin.py
+"""
+Hosting Admin Page Routes (HTML rendering).
+
+Admin pages for hosted site management:
+- Dashboard - Overview with KPIs and charts
+- Sites - Hosted site list with filters
+- Site Detail - Single site view with tabs (overview, services, store link)
+- Site New - Create new hosted site form
+- Clients - All services overview with expiry warnings
+"""
+
+from fastapi import APIRouter, Depends, Path, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
+
+from app.api.deps import get_db, require_menu_access
+from app.modules.core.utils.page_context import get_admin_context
+from app.modules.enums import FrontendType
+from app.modules.tenancy.models import User
+from app.templates_config import templates
+
+router = APIRouter()
+
+
+@router.get("/hosting", response_class=HTMLResponse, include_in_schema=False)
+async def admin_hosting_dashboard(
+ request: Request,
+ current_user: User = Depends(require_menu_access("hosting-dashboard", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """Render hosting dashboard page."""
+ return templates.TemplateResponse(
+ "hosting/admin/dashboard.html",
+ get_admin_context(request, db, current_user),
+ )
+
+
+@router.get("/hosting/sites", response_class=HTMLResponse, include_in_schema=False)
+async def admin_hosting_sites(
+ request: Request,
+ current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """Render hosted sites list page."""
+ return templates.TemplateResponse(
+ "hosting/admin/sites.html",
+ get_admin_context(request, db, current_user),
+ )
+
+
+@router.get("/hosting/sites/new", response_class=HTMLResponse, include_in_schema=False)
+async def admin_hosting_site_new(
+ request: Request,
+ current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """Render new hosted site form."""
+ return templates.TemplateResponse(
+ "hosting/admin/site-new.html",
+ get_admin_context(request, db, current_user),
+ )
+
+
+@router.get(
+ "/hosting/sites/{site_id}",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def admin_hosting_site_detail(
+ request: Request,
+ site_id: int = Path(..., description="Hosted Site ID"),
+ current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """Render hosted site detail page."""
+ context = get_admin_context(request, db, current_user)
+ context["site_id"] = site_id
+ return templates.TemplateResponse(
+ "hosting/admin/site-detail.html",
+ context,
+ )
+
+
+@router.get("/hosting/clients", response_class=HTMLResponse, include_in_schema=False)
+async def admin_hosting_clients(
+ request: Request,
+ current_user: User = Depends(require_menu_access("hosting-clients", FrontendType.ADMIN)),
+ db: Session = Depends(get_db),
+):
+ """Render all client services overview page."""
+ return templates.TemplateResponse(
+ "hosting/admin/clients.html",
+ get_admin_context(request, db, current_user),
+ )
diff --git a/app/modules/hosting/routes/pages/public.py b/app/modules/hosting/routes/pages/public.py
new file mode 100644
index 00000000..fb7104ef
--- /dev/null
+++ b/app/modules/hosting/routes/pages/public.py
@@ -0,0 +1,46 @@
+# app/modules/hosting/routes/pages/public.py
+"""
+Hosting Public Page Routes.
+
+Public-facing routes for POC site viewing:
+- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
+"""
+
+from fastapi import APIRouter, Depends, Path, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.templates_config import templates
+
+router = APIRouter()
+
+
+@router.get(
+ "/hosting/sites/{site_id}/preview",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def poc_site_viewer(
+ request: Request,
+ site_id: int = Path(..., description="Hosted Site ID"),
+ db: Session = Depends(get_db),
+):
+ """Render POC site viewer with HostWizard preview banner."""
+ from app.modules.hosting.models import HostedSite, HostedSiteStatus
+
+ site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
+
+ # Only allow viewing for poc_ready or proposal_sent sites
+ if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
+ return HTMLResponse(content="
Site not available for preview
", status_code=404)
+
+ context = {
+ "request": request,
+ "site": site,
+ "store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
+ }
+ return templates.TemplateResponse(
+ "hosting/public/poc-viewer.html",
+ context,
+ )
diff --git a/app/modules/hosting/schemas/__init__.py b/app/modules/hosting/schemas/__init__.py
new file mode 100644
index 00000000..31c15410
--- /dev/null
+++ b/app/modules/hosting/schemas/__init__.py
@@ -0,0 +1,32 @@
+# app/modules/hosting/schemas/__init__.py
+from app.modules.hosting.schemas.client_service import (
+ ClientServiceCreate,
+ ClientServiceDeleteResponse,
+ ClientServiceResponse,
+ ClientServiceUpdate,
+)
+from app.modules.hosting.schemas.hosted_site import (
+ AcceptProposalRequest,
+ GoLiveRequest,
+ HostedSiteCreate,
+ HostedSiteDetailResponse,
+ HostedSiteListResponse,
+ HostedSiteResponse,
+ HostedSiteUpdate,
+ SendProposalRequest,
+)
+
+__all__ = [
+ "HostedSiteCreate",
+ "HostedSiteUpdate",
+ "HostedSiteResponse",
+ "HostedSiteDetailResponse",
+ "HostedSiteListResponse",
+ "SendProposalRequest",
+ "AcceptProposalRequest",
+ "GoLiveRequest",
+ "ClientServiceCreate",
+ "ClientServiceUpdate",
+ "ClientServiceResponse",
+ "ClientServiceDeleteResponse",
+]
diff --git a/app/modules/hosting/schemas/client_service.py b/app/modules/hosting/schemas/client_service.py
new file mode 100644
index 00000000..c6239653
--- /dev/null
+++ b/app/modules/hosting/schemas/client_service.py
@@ -0,0 +1,80 @@
+# app/modules/hosting/schemas/client_service.py
+"""Pydantic schemas for client service management."""
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class ClientServiceCreate(BaseModel):
+ """Schema for creating a client service."""
+
+ service_type: str = Field(..., pattern="^(domain|email|ssl|hosting|website_maintenance)$")
+ name: str = Field(..., max_length=255)
+ description: str | None = None
+ billing_period: str | None = Field(None, pattern="^(monthly|annual|one_time)$")
+ price_cents: int | None = None
+ currency: str = Field("EUR", max_length=3)
+ addon_product_id: int | None = None
+ domain_name: str | None = Field(None, max_length=255)
+ registrar: str | None = Field(None, max_length=100)
+ mailbox_count: int | None = None
+ expires_at: datetime | None = None
+ period_start: datetime | None = None
+ period_end: datetime | None = None
+ auto_renew: bool = True
+ notes: str | None = None
+
+
+class ClientServiceUpdate(BaseModel):
+ """Schema for updating a client service."""
+
+ name: str | None = Field(None, max_length=255)
+ description: str | None = None
+ status: str | None = Field(None, pattern="^(pending|active|suspended|expired|cancelled)$")
+ billing_period: str | None = Field(None, pattern="^(monthly|annual|one_time)$")
+ price_cents: int | None = None
+ currency: str | None = Field(None, max_length=3)
+ addon_product_id: int | None = None
+ domain_name: str | None = Field(None, max_length=255)
+ registrar: str | None = Field(None, max_length=100)
+ mailbox_count: int | None = None
+ expires_at: datetime | None = None
+ period_start: datetime | None = None
+ period_end: datetime | None = None
+ auto_renew: bool | None = None
+ notes: str | None = None
+
+
+class ClientServiceResponse(BaseModel):
+ """Schema for client service response."""
+
+ id: int
+ hosted_site_id: int
+ service_type: str
+ name: str
+ description: str | None = None
+ status: str
+ billing_period: str | None = None
+ price_cents: int | None = None
+ currency: str = "EUR"
+ addon_product_id: int | None = None
+ domain_name: str | None = None
+ registrar: str | None = None
+ mailbox_count: int | None = None
+ expires_at: datetime | None = None
+ period_start: datetime | None = None
+ period_end: datetime | None = None
+ auto_renew: bool = True
+ notes: str | None = None
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class ClientServiceDeleteResponse(BaseModel):
+ """Response for client service deletion."""
+
+ message: str
diff --git a/app/modules/hosting/schemas/hosted_site.py b/app/modules/hosting/schemas/hosted_site.py
new file mode 100644
index 00000000..e81aafc3
--- /dev/null
+++ b/app/modules/hosting/schemas/hosted_site.py
@@ -0,0 +1,101 @@
+# app/modules/hosting/schemas/hosted_site.py
+"""Pydantic schemas for hosted site management."""
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class HostedSiteCreate(BaseModel):
+ """Schema for creating a hosted site."""
+
+ business_name: str = Field(..., max_length=255)
+ contact_name: str | None = Field(None, max_length=255)
+ contact_email: str | None = Field(None, max_length=255)
+ contact_phone: str | None = Field(None, max_length=50)
+ internal_notes: str | None = None
+
+
+class HostedSiteUpdate(BaseModel):
+ """Schema for updating a hosted site."""
+
+ business_name: str | None = Field(None, max_length=255)
+ contact_name: str | None = Field(None, max_length=255)
+ contact_email: str | None = Field(None, max_length=255)
+ contact_phone: str | None = Field(None, max_length=50)
+ internal_notes: str | None = None
+
+
+class SendProposalRequest(BaseModel):
+ """Schema for sending a proposal."""
+
+ notes: str | None = None
+
+
+class AcceptProposalRequest(BaseModel):
+ """Schema for accepting a proposal."""
+
+ merchant_id: int | None = None
+
+
+class GoLiveRequest(BaseModel):
+ """Schema for going live with a domain."""
+
+ domain: str = Field(..., max_length=255)
+
+
+class HostedSiteResponse(BaseModel):
+ """Schema for hosted site response."""
+
+ id: int
+ store_id: int
+ prospect_id: int | None = None
+ status: str
+ business_name: str
+ contact_name: str | None = None
+ contact_email: str | None = None
+ contact_phone: str | None = None
+ proposal_sent_at: datetime | None = None
+ proposal_accepted_at: datetime | None = None
+ went_live_at: datetime | None = None
+ proposal_notes: str | None = None
+ live_domain: str | None = None
+ internal_notes: str | None = None
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class HostedSiteDetailResponse(HostedSiteResponse):
+ """Full hosted site detail with services."""
+
+ client_services: list["ClientServiceResponse"] = []
+
+ class Config:
+ from_attributes = True
+
+
+class HostedSiteListResponse(BaseModel):
+ """Paginated hosted site list response."""
+
+ items: list[HostedSiteResponse]
+ total: int
+ page: int
+ per_page: int
+ pages: int
+
+
+class HostedSiteDeleteResponse(BaseModel):
+ """Response for hosted site deletion."""
+
+ message: str
+
+
+# Forward references
+from app.modules.hosting.schemas.client_service import (
+ ClientServiceResponse, # noqa: E402
+)
+
+HostedSiteDetailResponse.model_rebuild()
diff --git a/app/modules/hosting/services/__init__.py b/app/modules/hosting/services/__init__.py
new file mode 100644
index 00000000..b62f6cd9
--- /dev/null
+++ b/app/modules/hosting/services/__init__.py
@@ -0,0 +1 @@
+# app/modules/hosting/services/__init__.py
diff --git a/app/modules/hosting/services/client_service_service.py b/app/modules/hosting/services/client_service_service.py
new file mode 100644
index 00000000..05db1c80
--- /dev/null
+++ b/app/modules/hosting/services/client_service_service.py
@@ -0,0 +1,128 @@
+# app/modules/hosting/services/client_service_service.py
+"""
+Client service CRUD service.
+
+Manages operational tracking for hosted site services (domains, email, SSL, etc.).
+"""
+
+import logging
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy.orm import Session
+
+from app.modules.hosting.exceptions import ClientServiceNotFoundException
+from app.modules.hosting.models import (
+ ClientService,
+ ClientServiceStatus,
+ ServiceType,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ClientServiceService:
+ """Service for client service CRUD operations."""
+
+ def get_by_id(self, db: Session, service_id: int) -> ClientService:
+ service = db.query(ClientService).filter(ClientService.id == service_id).first()
+ if not service:
+ raise ClientServiceNotFoundException(str(service_id))
+ return service
+
+ def get_for_site(self, db: Session, hosted_site_id: int) -> list[ClientService]:
+ return (
+ db.query(ClientService)
+ .filter(ClientService.hosted_site_id == hosted_site_id)
+ .order_by(ClientService.created_at.desc())
+ .all()
+ )
+
+ def create(self, db: Session, hosted_site_id: int, data: dict) -> ClientService:
+ service = ClientService(
+ hosted_site_id=hosted_site_id,
+ service_type=ServiceType(data["service_type"]),
+ name=data["name"],
+ description=data.get("description"),
+ status=ClientServiceStatus.PENDING,
+ billing_period=data.get("billing_period"),
+ price_cents=data.get("price_cents"),
+ currency=data.get("currency", "EUR"),
+ addon_product_id=data.get("addon_product_id"),
+ domain_name=data.get("domain_name"),
+ registrar=data.get("registrar"),
+ mailbox_count=data.get("mailbox_count"),
+ expires_at=data.get("expires_at"),
+ period_start=data.get("period_start"),
+ period_end=data.get("period_end"),
+ auto_renew=data.get("auto_renew", True),
+ notes=data.get("notes"),
+ )
+ db.add(service)
+ db.flush()
+ logger.info("Created client service: %s (site_id=%d)", service.name, hosted_site_id)
+ return service
+
+ def update(self, db: Session, service_id: int, data: dict) -> ClientService:
+ service = self.get_by_id(db, service_id)
+
+ for field in [
+ "name", "description", "status", "billing_period", "price_cents",
+ "currency", "addon_product_id", "domain_name", "registrar",
+ "mailbox_count", "expires_at", "period_start", "period_end",
+ "auto_renew", "notes",
+ ]:
+ if field in data and data[field] is not None:
+ setattr(service, field, data[field])
+
+ db.flush()
+ return service
+
+ def delete(self, db: Session, service_id: int) -> bool:
+ service = self.get_by_id(db, service_id)
+ db.delete(service)
+ db.flush()
+ logger.info("Deleted client service: %d", service_id)
+ return True
+
+ def get_expiring_soon(self, db: Session, days: int = 30) -> list[ClientService]:
+ """Get services expiring within the given number of days."""
+ cutoff = datetime.now(UTC) + timedelta(days=days)
+ return (
+ db.query(ClientService)
+ .filter(
+ ClientService.expires_at.isnot(None),
+ ClientService.expires_at <= cutoff,
+ ClientService.status == ClientServiceStatus.ACTIVE,
+ )
+ .order_by(ClientService.expires_at.asc())
+ .all()
+ )
+
+ def get_all(
+ self,
+ db: Session,
+ *,
+ page: int = 1,
+ per_page: int = 20,
+ service_type: str | None = None,
+ status: str | None = None,
+ ) -> tuple[list[ClientService], int]:
+ """Get all services with pagination and filters."""
+ query = db.query(ClientService)
+
+ if service_type:
+ query = query.filter(ClientService.service_type == service_type)
+ if status:
+ query = query.filter(ClientService.status == status)
+
+ total = query.count()
+ services = (
+ query.order_by(ClientService.created_at.desc())
+ .offset((page - 1) * per_page)
+ .limit(per_page)
+ .all()
+ )
+ return services, total
+
+
+client_service_service = ClientServiceService()
diff --git a/app/modules/hosting/services/hosted_site_service.py b/app/modules/hosting/services/hosted_site_service.py
new file mode 100644
index 00000000..f5fea879
--- /dev/null
+++ b/app/modules/hosting/services/hosted_site_service.py
@@ -0,0 +1,325 @@
+# 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()
diff --git a/app/modules/hosting/services/stats_service.py b/app/modules/hosting/services/stats_service.py
new file mode 100644
index 00000000..6c374fdf
--- /dev/null
+++ b/app/modules/hosting/services/stats_service.py
@@ -0,0 +1,101 @@
+# app/modules/hosting/services/stats_service.py
+"""
+Statistics service for the hosting dashboard.
+"""
+
+import logging
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.modules.hosting.models import (
+ ClientService,
+ ClientServiceStatus,
+ HostedSite,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class StatsService:
+ """Service for dashboard statistics and reporting."""
+
+ def get_dashboard_stats(self, db: Session) -> dict:
+ """Get overview statistics for the hosting dashboard."""
+ total = db.query(func.count(HostedSite.id)).scalar() or 0
+
+ # Sites by status
+ status_results = (
+ db.query(HostedSite.status, func.count(HostedSite.id))
+ .group_by(HostedSite.status)
+ .all()
+ )
+ sites_by_status = {
+ status.value if hasattr(status, "value") else str(status): count
+ for status, count in status_results
+ }
+
+ live_count = sites_by_status.get("live", 0)
+ poc_count = (
+ sites_by_status.get("draft", 0)
+ + sites_by_status.get("poc_ready", 0)
+ + sites_by_status.get("proposal_sent", 0)
+ )
+
+ # Active services
+ active_services = (
+ db.query(func.count(ClientService.id))
+ .filter(ClientService.status == ClientServiceStatus.ACTIVE)
+ .scalar()
+ or 0
+ )
+
+ # Monthly revenue (sum of price_cents for active services with monthly billing)
+ monthly_revenue = (
+ db.query(func.sum(ClientService.price_cents))
+ .filter(
+ ClientService.status == ClientServiceStatus.ACTIVE,
+ )
+ .scalar()
+ or 0
+ )
+
+ # Upcoming renewals (next 30 days)
+ cutoff = datetime.now(UTC) + timedelta(days=30)
+ upcoming_renewals = (
+ db.query(func.count(ClientService.id))
+ .filter(
+ ClientService.expires_at.isnot(None),
+ ClientService.expires_at <= cutoff,
+ ClientService.status == ClientServiceStatus.ACTIVE,
+ )
+ .scalar()
+ or 0
+ )
+
+ # Services by type
+ type_results = (
+ db.query(ClientService.service_type, func.count(ClientService.id))
+ .filter(ClientService.status == ClientServiceStatus.ACTIVE)
+ .group_by(ClientService.service_type)
+ .all()
+ )
+ services_by_type = {
+ stype.value if hasattr(stype, "value") else str(stype): count
+ for stype, count in type_results
+ }
+
+ return {
+ "total_sites": total,
+ "live_sites": live_count,
+ "poc_sites": poc_count,
+ "sites_by_status": sites_by_status,
+ "active_services": active_services,
+ "monthly_revenue_cents": monthly_revenue,
+ "upcoming_renewals": upcoming_renewals,
+ "services_by_type": services_by_type,
+ }
+
+
+stats_service = StatsService()
diff --git a/app/modules/hosting/static/admin/js/.gitkeep b/app/modules/hosting/static/admin/js/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/app/modules/hosting/tasks/__init__.py b/app/modules/hosting/tasks/__init__.py
new file mode 100644
index 00000000..a3d5aaab
--- /dev/null
+++ b/app/modules/hosting/tasks/__init__.py
@@ -0,0 +1 @@
+# app/modules/hosting/tasks/__init__.py
diff --git a/app/modules/hosting/templates/hosting/admin/clients.html b/app/modules/hosting/templates/hosting/admin/clients.html
new file mode 100644
index 00000000..beb8232e
--- /dev/null
+++ b/app/modules/hosting/templates/hosting/admin/clients.html
@@ -0,0 +1,165 @@
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/pagination.html' import pagination_controls %}
+
+{% block title %}Client Services{% endblock %}
+
+{% block alpine_data %}hostingClientsList(){% endblock %}
+
+{% block content %}
+{{ page_header('Client Services') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ loading_state('Loading services...') }}
+{{ error_state('Error loading services') }}
+
+
+
+
+
+
+
+ | Service |
+ Type |
+ Status |
+ Price |
+ Expires |
+ Site |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ View Site
+
+ |
+
+
+
+
+
+
+ No services found
+
+
+
+{{ pagination_controls() }}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/hosting/templates/hosting/admin/dashboard.html b/app/modules/hosting/templates/hosting/admin/dashboard.html
new file mode 100644
index 00000000..596da45c
--- /dev/null
+++ b/app/modules/hosting/templates/hosting/admin/dashboard.html
@@ -0,0 +1,148 @@
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+
+{% block title %}Hosting Dashboard{% endblock %}
+
+{% block alpine_data %}hostingDashboard(){% endblock %}
+
+{% block content %}
+{{ page_header('Hosting Dashboard') }}
+{{ loading_state('Loading dashboard...') }}
+{{ error_state('Error loading dashboard') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Active Services
+
+
+ Total Active
+
+
+
+
+
+
+
+
+
+
+
+ Monthly Revenue
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/hosting/templates/hosting/admin/site-detail.html b/app/modules/hosting/templates/hosting/admin/site-detail.html
new file mode 100644
index 00000000..c2fffbda
--- /dev/null
+++ b/app/modules/hosting/templates/hosting/admin/site-detail.html
@@ -0,0 +1,405 @@
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/inputs.html' import number_stepper %}
+
+{% block title %}Site Detail{% endblock %}
+
+{% block alpine_data %}hostingSiteDetail({{ site_id }}){% endblock %}
+
+{% block content %}
+{{ loading_state('Loading site...') }}
+{{ error_state('Error loading site') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview POC
+
+
+
+
+
+
+
+
+
+
+
+
Contact Info
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Phone
+
+
+
+
+
+
Timeline
+
+
+ Created
+
+
+
+ Proposal Sent
+
+
+
+ Accepted
+
+
+
+ Went Live
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No services yet
+
+
+
+
+
+
+ {# noqa: FE-004 #}
+
+
+
+
+
+
+
+
+
+
+
+ {# noqa: FE-004 #}
+
+
+
+ Accept Proposal
+
+
+
+
+
+
+
+
+
+
+ {# noqa: FE-004 #}
+
+
+ {# noqa: FE-004 #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/hosting/templates/hosting/admin/site-new.html b/app/modules/hosting/templates/hosting/admin/site-new.html
new file mode 100644
index 00000000..925bacc1
--- /dev/null
+++ b/app/modules/hosting/templates/hosting/admin/site-new.html
@@ -0,0 +1,115 @@
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import page_header %}
+
+{% block title %}New Hosted Site{% endblock %}
+
+{% block alpine_data %}hostingSiteNew(){% endblock %}
+
+{% block content %}
+{{ page_header('New Hosted Site') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/hosting/templates/hosting/admin/sites.html b/app/modules/hosting/templates/hosting/admin/sites.html
new file mode 100644
index 00000000..7f371d9a
--- /dev/null
+++ b/app/modules/hosting/templates/hosting/admin/sites.html
@@ -0,0 +1,152 @@
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import page_header %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/tables.html' import table_header, table_empty %}
+{% from 'shared/macros/pagination.html' import pagination_controls %}
+
+{% block title %}Hosted Sites{% endblock %}
+
+{% block alpine_data %}hostingSitesList(){% endblock %}
+
+{% block content %}
+{{ page_header('Hosted Sites', action_label='New Site', action_href='/admin/hosting/sites/new', action_icon='plus') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ loading_state('Loading sites...') }}
+{{ error_state('Error loading sites') }}
+
+
+
+
+
+
+
+ | Business |
+ Status |
+ Contact |
+ Domain |
+ Created |
+ Actions |
+
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ View
+
+ |
+
+
+
+
+
+ {{ table_empty('No hosted sites found') }}
+
+
+{{ pagination_controls() }}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/modules/hosting/templates/hosting/public/poc-viewer.html b/app/modules/hosting/templates/hosting/public/poc-viewer.html
new file mode 100644
index 00000000..fcb0e608
--- /dev/null
+++ b/app/modules/hosting/templates/hosting/public/poc-viewer.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ {{ site.business_name }} - Preview by HostWizard
+
+
+
+
+
+ HostWizard
+ Preview for {{ site.business_name }}
+
+
+
+
+
+
+
+
diff --git a/app/modules/prospecting/migrations/versions/prospecting_001_initial.py b/app/modules/prospecting/migrations/versions/prospecting_001_initial.py
index f0775aeb..1082d6fe 100644
--- a/app/modules/prospecting/migrations/versions/prospecting_001_initial.py
+++ b/app/modules/prospecting/migrations/versions/prospecting_001_initial.py
@@ -1,7 +1,7 @@
"""prospecting: initial tables for lead discovery and campaign management
Revision ID: prospecting_001
-Revises: None
+Revises: cms_002
Create Date: 2026-02-27
"""
import sqlalchemy as sa
@@ -9,8 +9,8 @@ import sqlalchemy as sa
from alembic import op
revision = "prospecting_001"
-down_revision = None
-branch_labels = ("prospecting",)
+down_revision = "cms_002"
+branch_labels = None
depends_on = None
diff --git a/docs/deployment/hetzner-server-setup.md b/docs/deployment/hetzner-server-setup.md
index 725e7631..889cde23 100644
--- a/docs/deployment/hetzner-server-setup.md
+++ b/docs/deployment/hetzner-server-setup.md
@@ -611,7 +611,16 @@ It should match the value in the Hetzner Cloud Console (Networking tab). Then cr
| AAAA | `git` | `2a01:4f8:1c1a:b39c::1` | 300 |
| AAAA | `flower` | `2a01:4f8:1c1a:b39c::1` | 300 |
-Repeat for `omsflow.lu` and `rewardflow.lu`.
+Repeat for `omsflow.lu`, `rewardflow.lu`, and `hostwizard.lu`.
+
+**hostwizard.lu DNS Records:**
+
+| Type | Name | Value | TTL |
+|---|---|---|---|
+| A | `@` | `91.99.65.229` | 300 |
+| A | `www` | `91.99.65.229` | 300 |
+| AAAA | `@` | `2a01:4f8:1c1a:b39c::1` | 300 |
+| AAAA | `www` | `2a01:4f8:1c1a:b39c::1` | 300 |
!!! tip "DNS propagation"
Set TTL to 300 (5 minutes) initially. DNS changes can take up to 24 hours to propagate globally, but usually complete within 30 minutes. Verify with: `dig api.wizard.lu +short`
@@ -661,6 +670,15 @@ www.rewardflow.lu {
redir https://rewardflow.lu{uri} permanent
}
+# ─── Platform 4: HostWizard (hostwizard.lu) ──────────────────
+hostwizard.lu {
+ reverse_proxy localhost:8001
+}
+
+www.hostwizard.lu {
+ redir https://hostwizard.lu{uri} permanent
+}
+
# ─── Services ───────────────────────────────────────────────
api.wizard.lu {
reverse_proxy localhost:8001
@@ -676,7 +694,7 @@ flower.wizard.lu {
```
!!! info "How multi-platform routing works"
- All platform domains (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`) point to the **same FastAPI backend** on port 8001. The `PlatformContextMiddleware` reads the `Host` header to detect which platform the request is for. Caddy preserves the Host header by default, so no extra configuration is needed.
+ All platform domains (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`, `hostwizard.lu`) point to the **same FastAPI backend** on port 8001. The `PlatformContextMiddleware` reads the `Host` header to detect which platform the request is for. Caddy preserves the Host header by default, so no extra configuration is needed.
The `domain` column in the `platforms` database table must match:
@@ -685,6 +703,7 @@ flower.wizard.lu {
| Main | `main` | `wizard.lu` |
| OMS | `oms` | `omsflow.lu` |
| Loyalty+ | `loyalty` | `rewardflow.lu` |
+ | HostWizard | `hosting` | `hostwizard.lu` |
Start Caddy:
@@ -1909,6 +1928,7 @@ Cloudflare Origin Certificates (free, 15-year validity) avoid ACME challenge iss
- `wizard.lu`: `wizard.lu, api.wizard.lu, www.wizard.lu, flower.wizard.lu, grafana.wizard.lu`
- `omsflow.lu`: `omsflow.lu, www.omsflow.lu`
- `rewardflow.lu`: `rewardflow.lu, www.rewardflow.lu`
+ - `hostwizard.lu`: `hostwizard.lu, www.hostwizard.lu`
3. Download the certificate and private key (private key is shown only once)
!!! warning "Do NOT use wildcard origin certs for wizard.lu"
@@ -1917,12 +1937,12 @@ Cloudflare Origin Certificates (free, 15-year validity) avoid ACME challenge iss
Install on the server:
```bash
-sudo mkdir -p /etc/caddy/certs/{wizard.lu,omsflow.lu,rewardflow.lu}
+sudo mkdir -p /etc/caddy/certs/{wizard.lu,omsflow.lu,rewardflow.lu,hostwizard.lu}
# For each domain, create cert.pem and key.pem:
sudo nano /etc/caddy/certs/wizard.lu/cert.pem # paste certificate
sudo nano /etc/caddy/certs/wizard.lu/key.pem # paste private key
-# Repeat for omsflow.lu and rewardflow.lu
+# Repeat for omsflow.lu, rewardflow.lu, and hostwizard.lu
sudo chown -R caddy:caddy /etc/caddy/certs/
sudo chmod 600 /etc/caddy/certs/*/key.pem
@@ -1976,6 +1996,17 @@ www.rewardflow.lu {
redir https://rewardflow.lu{uri} permanent
}
+# ─── Platform 4: HostWizard (hostwizard.lu) ──────────────────
+hostwizard.lu {
+ tls /etc/caddy/certs/hostwizard.lu/cert.pem /etc/caddy/certs/hostwizard.lu/key.pem
+ reverse_proxy localhost:8001
+}
+
+www.hostwizard.lu {
+ tls /etc/caddy/certs/hostwizard.lu/cert.pem /etc/caddy/certs/hostwizard.lu/key.pem
+ redir https://hostwizard.lu{uri} permanent
+}
+
# ─── Services (wizard.lu origin cert) ───────────────────────
api.wizard.lu {
tls /etc/caddy/certs/wizard.lu/cert.pem /etc/caddy/certs/wizard.lu/key.pem
@@ -2010,7 +2041,7 @@ sudo systemctl status caddy
### 21.6 Cloudflare Settings (per domain)
-Configure these in the Cloudflare dashboard for each domain (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`):
+Configure these in the Cloudflare dashboard for each domain (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`, `hostwizard.lu`):
| Setting | Location | Value |
|---|---|---|
diff --git a/docs/features/user-journeys/hosting.md b/docs/features/user-journeys/hosting.md
new file mode 100644
index 00000000..ecf68cd1
--- /dev/null
+++ b/docs/features/user-journeys/hosting.md
@@ -0,0 +1,502 @@
+# Hosting Module - User Journeys
+
+## Personas
+
+| # | Persona | Role / Auth | Description |
+|---|---------|-------------|-------------|
+| 1 | **Platform Admin** | `admin` role | Manages the POC → live website pipeline, tracks client services, monitors renewals |
+| 2 | **Prospect** | No auth (receives proposal link) | Views their POC website preview via a shared link |
+
+!!! note "Admin-only module"
+ The hosting module is primarily an admin-only module. The only non-admin page is the
+ **POC Viewer** — a public preview page that shows the prospect's POC website with a
+ HostWizard banner. Prospects do not have accounts until their proposal is accepted, at
+ which point a Merchant account is created for them.
+
+---
+
+## Lifecycle Overview
+
+The hosting module manages the complete POC → live website pipeline:
+
+```mermaid
+flowchart TD
+ A[Prospect identified] --> B[Create Hosted Site]
+ B --> C[Status: DRAFT]
+ C --> D[Build POC website via CMS]
+ D --> E[Mark POC Ready]
+ E --> F[Status: POC_READY]
+ F --> G[Send Proposal to prospect]
+ G --> H[Status: PROPOSAL_SENT]
+ H --> I{Prospect accepts?}
+ I -->|Yes| J[Accept Proposal]
+ J --> K[Status: ACCEPTED]
+ K --> L[Merchant account created]
+ L --> M[Go Live with domain]
+ M --> N[Status: LIVE]
+ I -->|No| O[Cancel]
+ O --> P[Status: CANCELLED]
+ N --> Q{Issues?}
+ Q -->|Payment issues| R[Suspend]
+ R --> S[Status: SUSPENDED]
+ S --> T[Reactivate → LIVE]
+ Q -->|Client leaves| O
+```
+
+### Status Transitions
+
+| From | Allowed Targets |
+|------|----------------|
+| `draft` | `poc_ready`, `cancelled` |
+| `poc_ready` | `proposal_sent`, `cancelled` |
+| `proposal_sent` | `accepted`, `cancelled` |
+| `accepted` | `live`, `cancelled` |
+| `live` | `suspended`, `cancelled` |
+| `suspended` | `live`, `cancelled` |
+| `cancelled` | _(terminal)_ |
+
+---
+
+## Dev URLs (localhost:9999)
+
+The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...`
+
+### 1. Admin Pages
+
+Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
+
+| Page | Dev URL |
+|------|---------|
+| Dashboard | `http://localhost:9999/platforms/hosting/admin/hosting` |
+| Sites List | `http://localhost:9999/platforms/hosting/admin/hosting/sites` |
+| New Site | `http://localhost:9999/platforms/hosting/admin/hosting/sites/new` |
+| Site Detail | `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` |
+| Client Services | `http://localhost:9999/platforms/hosting/admin/hosting/clients` |
+
+### 2. Public Pages
+
+| Page | Dev URL |
+|------|---------|
+| POC Viewer | `http://localhost:9999/platforms/hosting/hosting/sites/{site_id}/preview` |
+
+### 3. Admin API Endpoints
+
+**Sites** (prefix: `/platforms/hosting/api/admin/hosting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | list sites | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` |
+| GET | site detail | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` |
+| POST | create site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` |
+| POST | create from prospect | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}` |
+| PUT | update site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` |
+| DELETE | delete site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` |
+
+**Lifecycle** (prefix: `/platforms/hosting/api/admin/hosting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| POST | mark POC ready | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready` |
+| POST | send proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal` |
+| POST | accept proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept` |
+| POST | go live | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live` |
+| POST | suspend | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend` |
+| POST | cancel | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel` |
+
+**Client Services** (prefix: `/platforms/hosting/api/admin/hosting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | list services | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` |
+| POST | create service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` |
+| PUT | update service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` |
+| DELETE | delete service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` |
+
+**Stats** (prefix: `/platforms/hosting/api/admin/hosting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard` |
+
+---
+
+## Production URLs (hostwizard.lu)
+
+In production, the platform uses **domain-based routing**.
+
+### Admin Pages & API
+
+| Page / Endpoint | Production URL |
+|-----------------|----------------|
+| Dashboard | `https://hostwizard.lu/admin/hosting` |
+| Sites | `https://hostwizard.lu/admin/hosting/sites` |
+| New Site | `https://hostwizard.lu/admin/hosting/sites/new` |
+| Site Detail | `https://hostwizard.lu/admin/hosting/sites/{id}` |
+| Client Services | `https://hostwizard.lu/admin/hosting/clients` |
+| API - Sites | `GET https://hostwizard.lu/api/admin/hosting/sites` |
+| API - Stats | `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard` |
+
+### Public Pages
+
+| Page | Production URL |
+|------|----------------|
+| POC Viewer | `https://hostwizard.lu/hosting/sites/{site_id}/preview` |
+
+---
+
+## Data Model
+
+### Hosted Site
+
+```
+HostedSite
+├── id (PK)
+├── store_id (FK → stores.id, unique) # The CMS-powered website
+├── prospect_id (FK → prospects.id, nullable) # Origin prospect
+├── status: draft | poc_ready | proposal_sent | accepted | live | suspended | cancelled
+├── business_name (str)
+├── contact_name, contact_email, contact_phone
+├── proposal_sent_at, proposal_accepted_at, went_live_at (datetime)
+├── proposal_notes (text)
+├── live_domain (str, unique)
+├── internal_notes (text)
+├── created_at, updated_at
+└── Relationships: store, prospect, client_services
+```
+
+### Client Service
+
+```
+ClientService
+├── id (PK)
+├── hosted_site_id (FK → hosted_sites.id, CASCADE)
+├── service_type: domain | email | ssl | hosting | website_maintenance
+├── name (str) # e.g., "acme.lu domain", "5 mailboxes"
+├── status: pending | active | suspended | expired | cancelled
+├── billing_period: monthly | annual | one_time
+├── price_cents (int), currency (str, default EUR)
+├── addon_product_id (FK, nullable) # Link to billing product
+├── domain_name, registrar # Domain-specific
+├── mailbox_count # Email-specific
+├── expires_at, period_start, period_end, auto_renew
+├── notes (text)
+└── created_at, updated_at
+```
+
+---
+
+## User Journeys
+
+### Journey 1: Create Hosted Site from Prospect
+
+**Persona:** Platform Admin
+**Goal:** Convert a qualified prospect into a hosted site with a POC website
+
+**Prerequisite:** A prospect exists in the prospecting module (see [Prospecting Journeys](prospecting.md))
+
+```mermaid
+flowchart TD
+ A[View prospect in prospecting module] --> B[Click 'Create Hosted Site from Prospect']
+ B --> C[HostedSite created with status DRAFT]
+ C --> D[Store auto-created on hosting platform]
+ D --> E[Contact info pre-filled from prospect]
+ E --> F[Navigate to site detail]
+ F --> G[Build POC website via CMS editor]
+```
+
+**Steps:**
+
+1. Create hosted site from prospect:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/from-prospect/{prospect_id}`
+2. This automatically:
+ - Creates a Store on the hosting platform
+ - Creates a HostedSite record linked to the Store and Prospect
+ - Pre-fills business_name, contact_name, contact_email, contact_phone from prospect data
+3. View the new site:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}`
+ - Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}`
+4. Click the Store link to open the CMS editor and build the POC website
+
+---
+
+### Journey 2: Create Hosted Site Manually
+
+**Persona:** Platform Admin
+**Goal:** Create a hosted site without an existing prospect (e.g., direct referral)
+
+```mermaid
+flowchart TD
+ A[Navigate to New Site page] --> B[Fill in business details]
+ B --> C[Submit form]
+ C --> D[HostedSite + Store created]
+ D --> E[Navigate to site detail]
+ E --> F[Build POC website]
+```
+
+**Steps:**
+
+1. Navigate to New Site form:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/new`
+ - Prod: `https://hostwizard.lu/admin/hosting/sites/new`
+2. Create the site:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites`
+ - Body: `{ "business_name": "Boulangerie du Parc", "contact_name": "Jean Müller", "contact_email": "jean@boulangerie-parc.lu", "contact_phone": "+352 26 123 456" }`
+3. A Store is auto-created with subdomain `boulangerie-du-parc` on the hosting platform
+
+---
+
+### Journey 3: POC → Proposal Flow
+
+**Persona:** Platform Admin
+**Goal:** Build a POC website, mark it ready, and send a proposal to the prospect
+
+```mermaid
+flowchart TD
+ A[Site is DRAFT] --> B[Build POC website via CMS]
+ B --> C[Mark POC Ready]
+ C --> D[Site is POC_READY]
+ D --> E[Preview the POC site]
+ E --> F[Send Proposal with notes]
+ F --> G[Site is PROPOSAL_SENT]
+ G --> H[Share preview link with prospect]
+```
+
+**Steps:**
+
+1. Build the POC website using the Store's CMS editor (linked from site detail page)
+2. When the POC is ready, mark it:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/mark-poc-ready`
+3. Preview the POC site (public link, no auth needed):
+ - Dev: `http://localhost:9999/platforms/hosting/hosting/sites/{id}/preview`
+ - Prod: `https://hostwizard.lu/hosting/sites/{id}/preview`
+4. Send proposal to the prospect:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/send-proposal`
+ - Body: `{ "notes": "Custom website with 5 pages, domain registration included" }`
+5. Share the preview link with the prospect via email
+
+!!! info "POC Viewer"
+ The POC Viewer page renders the Store's storefront in an iframe with a teal
+ HostWizard banner at the top. It only works for sites with status `poc_ready`
+ or `proposal_sent`. Once the site goes live, the preview is disabled.
+
+---
+
+### Journey 4: Accept Proposal & Create Merchant
+
+**Persona:** Platform Admin
+**Goal:** When a prospect accepts, create their merchant account and subscription
+
+```mermaid
+flowchart TD
+ A[Prospect accepts proposal] --> B{Existing merchant?}
+ B -->|Yes| C[Link to existing merchant]
+ B -->|No| D[Auto-create merchant + owner account]
+ C --> E[Accept Proposal]
+ D --> E
+ E --> F[Site is ACCEPTED]
+ F --> G[Store reassigned to merchant]
+ G --> H[Subscription created on hosting platform]
+ H --> I[Prospect marked as CONVERTED]
+```
+
+**Steps:**
+
+1. Accept the proposal (auto-creates merchant if no merchant_id provided):
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/accept`
+ - Body: `{}` (auto-create merchant) or `{ "merchant_id": 5 }` (link to existing)
+2. This automatically:
+ - Creates a new Merchant from contact info (name, email, phone)
+ - Creates a store owner account with a temporary password
+ - Reassigns the Store from the system merchant to the new merchant
+ - Creates a MerchantSubscription on the hosting platform (essential tier)
+ - Marks the linked prospect as CONVERTED (if prospect_id is set)
+3. View the updated site detail:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{id}`
+ - Prod: `https://hostwizard.lu/admin/hosting/sites/{id}`
+
+!!! warning "Merchant account credentials"
+ When accepting without an existing `merchant_id`, a new merchant owner account is
+ created with a temporary password. The admin should communicate these credentials
+ to the client so they can log in and self-edit their website via the CMS.
+
+---
+
+### Journey 5: Go Live with Custom Domain
+
+**Persona:** Platform Admin
+**Goal:** Assign a production domain to the website and make it live
+
+```mermaid
+flowchart TD
+ A[Site is ACCEPTED] --> B[Configure DNS for client domain]
+ B --> C[Go Live with domain]
+ C --> D[Site is LIVE]
+ D --> E[StoreDomain created]
+ E --> F[Website accessible at client domain]
+```
+
+**Steps:**
+
+1. Ensure DNS is configured for the client's domain (A/AAAA records pointing to the server)
+2. Go live:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/go-live`
+ - Body: `{ "domain": "boulangerie-parc.lu" }`
+3. This automatically:
+ - Sets `went_live_at` timestamp
+ - Creates a StoreDomain record (primary) for the domain
+ - Sets `live_domain` on the hosted site
+4. The website is now accessible at `https://boulangerie-parc.lu`
+
+---
+
+### Journey 6: Add Client Services
+
+**Persona:** Platform Admin
+**Goal:** Track operational services (domains, email, SSL, hosting) for a client
+
+```mermaid
+flowchart TD
+ A[Open site detail] --> B[Go to Services tab]
+ B --> C[Add domain service]
+ C --> D[Add email service]
+ D --> E[Add SSL service]
+ E --> F[Add hosting service]
+ F --> G[Services tracked with expiry dates]
+```
+
+**Steps:**
+
+1. Navigate to site detail, Services tab:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}`
+ - Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}`
+2. Add a domain service:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services`
+ - Body: `{ "service_type": "domain", "name": "boulangerie-parc.lu domain", "domain_name": "boulangerie-parc.lu", "registrar": "Namecheap", "billing_period": "annual", "price_cents": 1500, "expires_at": "2027-03-01T00:00:00", "auto_renew": true }`
+3. Add an email service:
+ - Body: `{ "service_type": "email", "name": "5 mailboxes", "mailbox_count": 5, "billing_period": "monthly", "price_cents": 999 }`
+4. Add an SSL service:
+ - Body: `{ "service_type": "ssl", "name": "SSL certificate", "billing_period": "annual", "price_cents": 0, "expires_at": "2027-03-01T00:00:00" }`
+5. View all services for a site:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services`
+ - API Prod: `GET https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services`
+
+---
+
+### Journey 7: Dashboard & Renewal Monitoring
+
+**Persona:** Platform Admin
+**Goal:** Monitor business KPIs and upcoming service renewals
+
+```mermaid
+flowchart TD
+ A[Navigate to Dashboard] --> B[View KPIs]
+ B --> C[Total sites, live sites, POC sites]
+ C --> D[Monthly revenue]
+ D --> E[Active services count]
+ E --> F[Upcoming renewals in 30 days]
+ F --> G[Navigate to Client Services]
+ G --> H[Filter by expiring soon]
+ H --> I[Renew or update services]
+```
+
+**Steps:**
+
+1. Navigate to Dashboard:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/hosting`
+ - Prod: `https://hostwizard.lu/admin/hosting`
+2. View dashboard stats:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard`
+ - API Prod: `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard`
+ - Returns: `total_sites`, `live_sites`, `poc_sites`, `sites_by_status`, `active_services`, `monthly_revenue_cents`, `upcoming_renewals`, `services_by_type`
+3. Navigate to Client Services for detailed view:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/clients`
+ - Prod: `https://hostwizard.lu/admin/hosting/clients`
+4. Filter by type (domain, email, ssl, hosting) or status
+5. Toggle "Expiring Soon" to see services expiring within 30 days
+
+---
+
+### Journey 8: Suspend & Reactivate
+
+**Persona:** Platform Admin
+**Goal:** Handle suspension (e.g., unpaid invoices) and reactivation
+
+**Steps:**
+
+1. Suspend a site:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/suspend`
+2. Site status changes to `suspended`
+3. Once payment is resolved, reactivate by transitioning back to live:
+ - The `suspended → live` transition is allowed
+4. To permanently close a site:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel`
+ - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/cancel`
+5. `cancelled` is a terminal state — no further transitions allowed
+
+---
+
+### Journey 9: Complete Pipeline (Prospect → Live Site)
+
+**Persona:** Platform Admin
+**Goal:** Walk the complete pipeline from prospect to live website
+
+This journey combines the prospecting and hosting modules end-to-end:
+
+```mermaid
+flowchart TD
+ A[Import domain / capture lead] --> B[Enrich & score prospect]
+ B --> C[Create hosted site from prospect]
+ C --> D[Build POC website via CMS]
+ D --> E[Mark POC ready]
+ E --> F[Send proposal + share preview link]
+ F --> G{Prospect accepts?}
+ G -->|Yes| H[Accept → Merchant created]
+ H --> I[Add client services]
+ I --> J[Go live with domain]
+ J --> K[Website live at client domain]
+ K --> L[Monitor renewals & services]
+ G -->|No| M[Cancel or follow up later]
+```
+
+**Steps:**
+
+1. **Prospecting phase** (see [Prospecting Journeys](prospecting.md)):
+ - Import domain or capture lead offline
+ - Run enrichment pipeline
+ - Score and qualify the prospect
+2. **Create hosted site**: `POST /api/admin/hosting/sites/from-prospect/{prospect_id}`
+3. **Build POC**: Edit the auto-created Store via CMS
+4. **Mark POC ready**: `POST /api/admin/hosting/sites/{id}/mark-poc-ready`
+5. **Send proposal**: `POST /api/admin/hosting/sites/{id}/send-proposal`
+6. **Share preview**: Send `https://hostwizard.lu/hosting/sites/{id}/preview` to prospect
+7. **Accept proposal**: `POST /api/admin/hosting/sites/{id}/accept`
+8. **Add services**: `POST /api/admin/hosting/sites/{id}/services` (domain, email, SSL, hosting)
+9. **Go live**: `POST /api/admin/hosting/sites/{id}/go-live` with domain
+10. **Monitor**: Dashboard at `https://hostwizard.lu/admin/hosting`
+
+---
+
+## Recommended Test Order
+
+1. **Journey 2** - Create a site manually first (simplest path, no prospect dependency)
+2. **Journey 3** - Walk the POC → proposal flow
+3. **Journey 4** - Accept proposal and verify merchant creation
+4. **Journey 5** - Go live with a test domain
+5. **Journey 6** - Add client services
+6. **Journey 7** - Check dashboard stats
+7. **Journey 1** - Test the prospect → hosted site conversion (requires prospecting data)
+8. **Journey 8** - Test suspend/reactivate/cancel
+9. **Journey 9** - Walk the complete end-to-end pipeline
+
+!!! tip "Test Journey 2 before Journey 1"
+ Journey 2 (manual creation) doesn't require any prospecting data and is the fastest
+ way to verify the hosting module works. Journey 1 (from prospect) requires running
+ the prospecting module first.
diff --git a/docs/features/user-journeys/prospecting.md b/docs/features/user-journeys/prospecting.md
new file mode 100644
index 00000000..abf4c470
--- /dev/null
+++ b/docs/features/user-journeys/prospecting.md
@@ -0,0 +1,435 @@
+# Prospecting Module - User Journeys
+
+## Personas
+
+| # | Persona | Role / Auth | Description |
+|---|---------|-------------|-------------|
+| 1 | **Platform Admin** | `admin` role | Manages prospects, runs enrichment scans, sends campaigns, exports leads |
+
+!!! note "Admin-only module"
+ The prospecting module is exclusively for platform admins. There are no store-level
+ or customer-facing pages. All access requires admin authentication.
+
+---
+
+## Platforms Using Prospecting
+
+The prospecting module is enabled on multiple platforms:
+
+| Platform | Domain | Path Prefix (dev) |
+|----------|--------|--------------------|
+| HostWizard | hostwizard.lu | `/platforms/hosting/` |
+
+---
+
+## Dev URLs (localhost:9999)
+
+The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...`
+
+### 1. Admin Pages
+
+Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
+
+| Page | Dev URL |
+|------|---------|
+| Dashboard | `http://localhost:9999/platforms/hosting/admin/prospecting` |
+| Prospects List | `http://localhost:9999/platforms/hosting/admin/prospecting/prospects` |
+| Prospect Detail | `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{prospect_id}` |
+| Leads List | `http://localhost:9999/platforms/hosting/admin/prospecting/leads` |
+| Quick Capture | `http://localhost:9999/platforms/hosting/admin/prospecting/capture` |
+| Scan Jobs | `http://localhost:9999/platforms/hosting/admin/prospecting/scan-jobs` |
+| Campaigns | `http://localhost:9999/platforms/hosting/admin/prospecting/campaigns` |
+
+### 2. Admin API Endpoints
+
+**Prospects** (prefix: `/platforms/hosting/api/admin/prospecting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | prospects | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` |
+| GET | prospect detail | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` |
+| POST | create prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` |
+| PUT | update prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` |
+| DELETE | delete prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` |
+| POST | import CSV | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/import` |
+
+**Leads** (prefix: `/platforms/hosting/api/admin/prospecting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | leads (filtered) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads` |
+| GET | top priority | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/top-priority` |
+| GET | quick wins | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/quick-wins` |
+| GET | export CSV | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv` |
+
+**Enrichment** (prefix: `/platforms/hosting/api/admin/prospecting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| POST | HTTP check (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/{id}` |
+| POST | HTTP check (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/batch` |
+| POST | tech scan (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/{id}` |
+| POST | tech scan (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/batch` |
+| POST | performance (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/{id}` |
+| POST | performance (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/batch` |
+| POST | contacts (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/contacts/{id}` |
+| POST | full enrichment | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{id}` |
+| POST | score compute | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/score-compute/batch` |
+
+**Interactions** (prefix: `/platforms/hosting/api/admin/prospecting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | prospect interactions | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` |
+| POST | log interaction | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` |
+| GET | upcoming follow-ups | `http://localhost:9999/platforms/hosting/api/admin/prospecting/interactions/upcoming` |
+
+**Campaigns** (prefix: `/platforms/hosting/api/admin/prospecting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | list templates | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` |
+| POST | create template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` |
+| PUT | update template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates/{id}` |
+| DELETE | delete template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates/{id}` |
+| POST | preview campaign | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/preview` |
+| POST | send campaign | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/send` |
+| GET | list sends | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/sends` |
+
+**Stats** (prefix: `/platforms/hosting/api/admin/prospecting/`):
+
+| Method | Endpoint | Dev URL |
+|--------|----------|---------|
+| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/prospecting/stats` |
+| GET | scan jobs | `http://localhost:9999/platforms/hosting/api/admin/prospecting/stats/jobs` |
+
+---
+
+## Production URLs (hostwizard.lu)
+
+In production, the platform uses **domain-based routing**.
+
+### Admin Pages & API
+
+| Page / Endpoint | Production URL |
+|-----------------|----------------|
+| Dashboard | `https://hostwizard.lu/admin/prospecting` |
+| Prospects | `https://hostwizard.lu/admin/prospecting/prospects` |
+| Prospect Detail | `https://hostwizard.lu/admin/prospecting/prospects/{id}` |
+| Leads | `https://hostwizard.lu/admin/prospecting/leads` |
+| Quick Capture | `https://hostwizard.lu/admin/prospecting/capture` |
+| Scan Jobs | `https://hostwizard.lu/admin/prospecting/scan-jobs` |
+| Campaigns | `https://hostwizard.lu/admin/prospecting/campaigns` |
+| API - Prospects | `GET https://hostwizard.lu/api/admin/prospecting/prospects` |
+| API - Leads | `GET https://hostwizard.lu/api/admin/prospecting/leads` |
+| API - Stats | `GET https://hostwizard.lu/api/admin/prospecting/stats` |
+
+---
+
+## Data Model
+
+### Prospect
+
+```
+Prospect
+├── id (PK)
+├── channel: DIGITAL | OFFLINE
+├── business_name (str)
+├── domain_name (str, unique)
+├── status: PENDING | ACTIVE | INACTIVE | PARKED | ERROR | CONTACTED | CONVERTED
+├── source (str)
+├── Digital fields: has_website, uses_https, http_status_code, redirect_url, scan timestamps
+├── Offline fields: address, city, postal_code, country, location_lat/lng, captured_by_user_id
+├── notes, tags (JSON)
+├── created_at, updated_at
+└── Relationships: tech_profile, performance_profile, score, contacts, interactions
+```
+
+### Prospect Score (0-100)
+
+```
+ProspectScore
+├── score (0-100, overall)
+├── Components: technical_health (max 40), modernity (max 25), business_value (max 25), engagement (max 10)
+├── reason_flags (JSON array)
+├── score_breakdown (JSON dict)
+└── lead_tier: top_priority | quick_win | strategic | low_priority
+```
+
+### Status Flow
+
+```
+PENDING
+ ↓ (HTTP check determines website status)
+ACTIVE (has website) or PARKED (no website / parked domain)
+ ↓ (contact attempt)
+CONTACTED
+ ↓ (outcome)
+CONVERTED (sale) or INACTIVE (not interested)
+
+Alternative: PENDING → ERROR (invalid domain, technical issues)
+```
+
+---
+
+## User Journeys
+
+### Journey 1: Digital Lead Discovery (Domain Scanning)
+
+**Persona:** Platform Admin
+**Goal:** Import `.lu` domains, enrich them, and identify sales opportunities
+
+```mermaid
+flowchart TD
+ A[Import CSV of .lu domains] --> B[Prospects created with status PENDING]
+ B --> C[Run HTTP check batch]
+ C --> D[Run tech scan batch]
+ D --> E[Run performance scan batch]
+ E --> F[Run contact scrape]
+ F --> G[Compute scores batch]
+ G --> H[View scored leads]
+ H --> I{Score tier?}
+ I -->|>= 70: Top Priority| J[Export & contact immediately]
+ I -->|50-69: Quick Win| K[Queue for campaign]
+ I -->|30-49: Strategic| L[Monitor & nurture]
+ I -->|< 30: Low Priority| M[Deprioritize]
+```
+
+**Steps:**
+
+1. Navigate to Dashboard:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting`
+ - Prod: `https://hostwizard.lu/admin/prospecting`
+2. Import domains via CSV:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/import`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/import`
+3. Run HTTP batch check:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/batch`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/http-check/batch`
+4. Run tech scan batch:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/batch`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/tech-scan/batch`
+5. Run performance scan batch:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/batch`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/performance/batch`
+6. Compute scores:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/score-compute/batch`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/score-compute/batch`
+7. Monitor scan jobs:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/scan-jobs`
+ - Prod: `https://hostwizard.lu/admin/prospecting/scan-jobs`
+8. View scored leads:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/leads`
+ - Prod: `https://hostwizard.lu/admin/prospecting/leads`
+9. Export top priority leads:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv?min_score=70`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/export/csv?min_score=70`
+
+---
+
+### Journey 2: Offline Lead Capture
+
+**Persona:** Platform Admin (out in the field)
+**Goal:** Capture business details from in-person encounters using the mobile-friendly capture form
+
+```mermaid
+flowchart TD
+ A[Meet business owner in-person] --> B[Open Quick Capture on mobile]
+ B --> C[Enter business name, address, contact info]
+ C --> D[Prospect created with channel=OFFLINE]
+ D --> E{Has website?}
+ E -->|Yes| F[Run full enrichment]
+ E -->|No| G[Score based on business value only]
+ F --> H[Prospect fully enriched with score]
+ G --> H
+ H --> I[Log interaction: VISIT]
+ I --> J[Set follow-up date]
+```
+
+**Steps:**
+
+1. Open Quick Capture (mobile-friendly):
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/capture`
+ - Prod: `https://hostwizard.lu/admin/prospecting/capture`
+2. Fill in business details and submit:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects`
+ - Body includes: `channel: "offline"`, `business_name`, `address`, `city`, `postal_code`
+3. Optionally run full enrichment (if domain known):
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{id}`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/full/{id}`
+4. Log the interaction:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions`
+ - Body: `{ "interaction_type": "visit", "notes": "Met at trade fair", "next_action": "Send proposal", "next_action_date": "2026-03-10" }`
+
+---
+
+### Journey 3: Lead Qualification & Export
+
+**Persona:** Platform Admin
+**Goal:** Filter enriched prospects by score tier and export qualified leads for outreach
+
+```mermaid
+flowchart TD
+ A[Navigate to Leads page] --> B[Filter by score tier]
+ B --> C{View preset lists}
+ C -->|Top Priority| D[Score >= 70]
+ C -->|Quick Wins| E[Score 50-69]
+ C -->|Custom filter| F[Set min/max score, channel, contact type]
+ D --> G[Review leads]
+ E --> G
+ F --> G
+ G --> H[Export as CSV]
+ H --> I[Use in campaigns or CRM]
+```
+
+**Steps:**
+
+1. Navigate to Leads:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/leads`
+ - Prod: `https://hostwizard.lu/admin/prospecting/leads`
+2. View top priority leads:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/top-priority`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/top-priority`
+3. View quick wins:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/quick-wins`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/quick-wins`
+4. Filter with custom parameters:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads?min_score=60&has_email=true&channel=digital`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads?min_score=60&has_email=true&channel=digital`
+5. Export filtered leads as CSV:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv?min_score=50&lead_tier=quick_win`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/export/csv?min_score=50&lead_tier=quick_win`
+
+---
+
+### Journey 4: Campaign Creation & Outreach
+
+**Persona:** Platform Admin
+**Goal:** Create email campaign templates and send targeted outreach to qualified prospects
+
+```mermaid
+flowchart TD
+ A[Navigate to Campaigns] --> B[Create campaign template]
+ B --> C[Choose lead type: no_website, bad_website, etc.]
+ C --> D[Write email template with variables]
+ D --> E[Preview rendered for specific prospect]
+ E --> F{Looks good?}
+ F -->|Yes| G[Select qualifying leads]
+ G --> H[Send campaign]
+ H --> I[Monitor send status]
+ F -->|No| J[Edit template]
+ J --> E
+```
+
+**Steps:**
+
+1. Navigate to Campaigns:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/campaigns`
+ - Prod: `https://hostwizard.lu/admin/prospecting/campaigns`
+2. Create a campaign template:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/templates`
+ - Body: `{ "name": "No Website Outreach", "lead_type": "no_website", "channel": "email", "language": "fr", "subject_template": "Votre presence en ligne", "body_template": "Bonjour {{business_name}}..." }`
+3. Preview the template for a specific prospect:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/preview`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/preview`
+ - Body: `{ "template_id": 1, "prospect_id": 42 }`
+4. Send campaign to selected prospects:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/send`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/send`
+ - Body: `{ "template_id": 1, "prospect_ids": [42, 43, 44] }`
+5. Monitor campaign sends:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/sends`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/campaigns/sends`
+
+---
+
+### Journey 5: Interaction Tracking & Follow-ups
+
+**Persona:** Platform Admin
+**Goal:** Log interactions with prospects and track follow-up actions
+
+```mermaid
+flowchart TD
+ A[Open prospect detail] --> B[View interaction history]
+ B --> C[Log new interaction]
+ C --> D[Set next action & date]
+ D --> E[View upcoming follow-ups]
+ E --> F[Complete follow-up]
+ F --> G{Positive outcome?}
+ G -->|Yes| H[Mark as CONTACTED → CONVERTED]
+ G -->|No| I[Schedule next follow-up or mark INACTIVE]
+```
+
+**Steps:**
+
+1. View prospect detail:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{id}`
+ - Prod: `https://hostwizard.lu/admin/prospecting/prospects/{id}`
+2. View interactions:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions`
+3. Log a new interaction:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions`
+ - Body: `{ "interaction_type": "call", "subject": "Follow-up call", "outcome": "positive", "next_action": "Send proposal", "next_action_date": "2026-03-15" }`
+4. View upcoming follow-ups across all prospects:
+ - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/interactions/upcoming`
+ - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/interactions/upcoming`
+
+---
+
+### Journey 6: Full Enrichment Pipeline (Single Prospect)
+
+**Persona:** Platform Admin
+**Goal:** Run the complete enrichment pipeline for a single prospect to get all data at once
+
+```mermaid
+flowchart TD
+ A[Open prospect detail] --> B[Click 'Full Enrichment']
+ B --> C[Step 1: HTTP check]
+ C --> D{Has website?}
+ D -->|Yes| E[Step 2: Tech scan]
+ D -->|No| H[Step 5: Compute score]
+ E --> F[Step 3: Performance audit]
+ F --> G[Step 4: Contact scrape]
+ G --> H
+ H --> I[Prospect fully enriched]
+ I --> J[View score & breakdown]
+```
+
+**Steps:**
+
+1. Run full enrichment for a prospect:
+ - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{prospect_id}`
+ - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/full/{prospect_id}`
+2. View the enriched prospect detail:
+ - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{prospect_id}`
+ - Prod: `https://hostwizard.lu/admin/prospecting/prospects/{prospect_id}`
+
+The full enrichment runs 5 sequential steps:
+
+1. **HTTP check** — Verifies domain connectivity, checks HTTPS, records redirects
+2. **Tech scan** — Detects CMS, server, hosting provider, JS framework, SSL cert details
+3. **Performance audit** — Runs PageSpeed analysis, records load times and scores
+4. **Contact scrape** — Extracts emails, phones, addresses, social links from the website
+5. **Score compute** — Calculates 0-100 opportunity score with component breakdown
+
+---
+
+## Recommended Test Order
+
+1. **Journey 1** (steps 1-3) - Import domains and run HTTP checks first
+2. **Journey 6** - Run full enrichment on a single prospect to test the complete pipeline
+3. **Journey 1** (steps 4-9) - Run batch scans and view scored leads
+4. **Journey 2** - Test offline capture on mobile
+5. **Journey 3** - Filter and export leads
+6. **Journey 4** - Create campaign templates and send to prospects
+7. **Journey 5** - Log interactions and check follow-ups
+
+!!! tip "Enrichment order matters"
+ The enrichment pipeline must run in order: HTTP check first (determines if website exists),
+ then tech scan, performance, and contacts (all require a live website). Score computation
+ should run last as it uses data from all other steps.
diff --git a/mkdocs.yml b/mkdocs.yml
index f8009427..54d0260a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -277,6 +277,8 @@ nav:
- Subscription & Billing: features/subscription-billing.md
- Email System: features/email-system.md
- User Journeys:
+ - Prospecting: features/user-journeys/prospecting.md
+ - Hosting: features/user-journeys/hosting.md
- Loyalty: features/user-journeys/loyalty.md
# --- User Guides ---
diff --git a/scripts/seed/init_production.py b/scripts/seed/init_production.py
index 1de690c4..b69267d1 100644
--- a/scripts/seed/init_production.py
+++ b/scripts/seed/init_production.py
@@ -55,6 +55,7 @@ for _mod in [
"app.modules.orders.models",
"app.modules.marketplace.models",
"app.modules.cms.models",
+ "app.modules.hosting.models",
]:
with contextlib.suppress(ImportError):
__import__(_mod)
@@ -194,6 +195,16 @@ def create_oms_admin(db: Session, auth_manager: AuthManager, oms_platform: Platf
)
+def create_hostwizard_admin(db: Session, auth_manager: AuthManager, hosting_platform: Platform) -> User | None:
+ """Create a platform admin for the HostWizard platform."""
+ return create_platform_admin(
+ db, auth_manager, hosting_platform,
+ username="hosting_admin",
+ email="admin@hostwizard.lu",
+ first_name="HostWizard",
+ )
+
+
def create_default_platforms(db: Session) -> list[Platform]:
"""Create all default platforms (OMS, Main, Loyalty+)."""
@@ -231,6 +242,17 @@ def create_default_platforms(db: Session) -> list[Platform]:
"settings": {"features": ["points", "rewards", "tiers", "analytics"]},
"theme_config": {"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"},
},
+ {
+ "code": "hosting",
+ "name": "HostWizard",
+ "description": "Web hosting, domains, email, and website building for Luxembourg businesses",
+ "domain": "hostwizard.lu",
+ "path_prefix": "hosting",
+ "default_language": "fr",
+ "supported_languages": ["fr", "de", "en", "lb"],
+ "settings": {"features": ["hosting", "domains", "email", "ssl", "poc_sites"]},
+ "theme_config": {"primary_color": "#0D9488", "secondary_color": "#14B8A6"},
+ },
]
platforms = []
@@ -526,6 +548,7 @@ def create_platform_modules(db: Session, platforms: list[Platform]) -> int:
"oms": ["inventory", "catalog", "orders", "marketplace", "analytics", "cart", "checkout"],
"main": ["analytics", "monitoring", "dev-tools"],
"loyalty": ["loyalty"],
+ "hosting": ["prospecting", "hosting", "analytics"],
}
now = datetime.now(UTC)
@@ -647,6 +670,12 @@ def initialize_production(db: Session, auth_manager: AuthManager):
else:
print_warning("Loyalty platform not found, skipping loyalty admin creation")
+ hosting_platform = next((p for p in platforms if p.code == "hosting"), None)
+ if hosting_platform:
+ create_hostwizard_admin(db, auth_manager, hosting_platform)
+ else:
+ print_warning("Hosting platform not found, skipping hosting admin creation")
+
# Step 4: Set up default role templates
print_step(4, "Setting up role templates...")
create_default_role_templates(db)
@@ -729,6 +758,11 @@ def print_summary(db: Session):
print(f" URL: {admin_url}")
print(" Username: loyalty_admin")
print(" Password: admin123") # noqa: SEC021
+ print()
+ print(" HostWizard Platform Admin (hosting only):")
+ print(f" URL: {admin_url}")
+ print(" Username: hosting_admin")
+ print(" Password: admin123") # noqa: SEC021
print("─" * 70)
# Show security warnings if in production