feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
CI / pytest (push) Failing after 49m20s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 10s

- Add complete hosting module (models, routes, schemas, services, templates, migrations)
- Add HostWizard platform to init_production seed (code=hosting, domain=hostwizard.lu)
- Fix cms_002 migration down_revision to z_unique_subdomain_domain
- Fix prospecting_001 migration to chain after cms_002 (remove branch label)
- Add hosting/prospecting version_locations to alembic.ini
- Fix admin_services delete endpoint to use proper response model
- Add hostwizard.lu to deployment docs (DNS, Caddy, Cloudflare)
- Add hosting and prospecting user journey docs to mkdocs nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:34:56 +01:00
parent 784bcb9d23
commit 8b147f53c6
46 changed files with 3907 additions and 13 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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")

View File

@@ -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}"

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/__init__.py

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/api/__init__.py

View File

@@ -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"])

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/pages/__init__.py

View File

@@ -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),
)

View File

@@ -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="<h1>Site not available for preview</h1>", 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,
)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1 @@
# app/modules/hosting/services/__init__.py

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -0,0 +1 @@
# app/modules/hosting/tasks/__init__.py

View File

@@ -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') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Service Type</label>
<select x-model="filterType" @change="loadServices()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="domain">Domain</option>
<option value="email">Email</option>
<option value="ssl">SSL</option>
<option value="hosting">Hosting</option>
<option value="website_maintenance">Website Maintenance</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadServices()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="expired">Expired</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="flex items-end">
<button @click="showExpiringOnly = !showExpiringOnly; loadServices()"
:class="showExpiringOnly ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="px-4 py-2 text-sm font-medium rounded-lg">
<span x-html="$icon('clock', 'w-4 h-4 inline mr-1')"></span>
Expiring Soon
</button>
</div>
</div>
</div>
{{ loading_state('Loading services...') }}
{{ error_state('Error loading services') }}
<!-- Services Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Service</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Expires</th>
<th class="px-4 py-3">Site</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="svc in services" :key="svc.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
:class="isExpiringSoon(svc) ? 'bg-red-50 dark:bg-red-900/20' : ''">
<td class="px-4 py-3 text-sm font-semibold" x-text="svc.name"></td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="svc.service_type.replace('_', ' ')"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="svc.status === 'active' ? 'bg-green-100 text-green-700' : svc.status === 'expired' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
x-text="svc.status"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="svc.price_cents ? '€' + (svc.price_cents / 100).toFixed(2) : '—'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'"
:class="isExpiringSoon(svc) ? 'text-red-600 font-semibold' : ''"></span>
</td>
<td class="px-4 py-3 text-sm">
<a :href="'/admin/hosting/sites/' + svc.hosted_site_id"
class="text-teal-600 hover:text-teal-900 dark:text-teal-400">
View Site
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="services.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500">
No services found
</div>
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script>
function hostingClientsList() {
return {
loading: true,
error: false,
services: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
filterType: '',
filterStatus: '',
showExpiringOnly: false,
async init() { await this.loadServices(); },
async loadServices() {
this.loading = true;
this.error = false;
try {
// Use the sites endpoint to get all services across all sites
const params = new URLSearchParams({ page: this.page, per_page: this.perPage });
if (this.filterStatus) params.set('status', this.filterStatus);
// Get sites and flatten their services
const resp = await fetch('/api/admin/hosting/sites?' + params);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
// Collect all services from all sites
let allServices = [];
for (const site of data.items) {
const svcResp = await fetch('/api/admin/hosting/sites/' + site.id + '/services');
if (svcResp.ok) {
const svcs = await svcResp.json();
allServices = allServices.concat(svcs);
}
}
// Apply client-side filters
if (this.filterType) allServices = allServices.filter(s => s.service_type === this.filterType);
if (this.filterStatus) allServices = allServices.filter(s => s.status === this.filterStatus);
if (this.showExpiringOnly) allServices = allServices.filter(s => this.isExpiringSoon(s));
this.services = allServices;
this.total = allServices.length;
} catch (e) { this.error = true; }
finally { this.loading = false; }
},
isExpiringSoon(svc) {
if (!svc.expires_at || svc.status !== 'active') return false;
const daysLeft = (new Date(svc.expires_at) - new Date()) / (1000 * 60 * 60 * 24);
return daysLeft <= 30 && daysLeft > 0;
},
goToPage(p) { this.page = p; this.loadServices(); },
};
}
</script>
{% endblock %}

View File

@@ -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') }}
<div x-show="!loading && !error" class="space-y-6">
<!-- KPI Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('globe', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Sites</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_sites || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Live Sites</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.live_sites || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">POC Sites</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.poc_sites || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Upcoming Renewals</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.upcoming_renewals || 0"></p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mb-6">
<a href="/admin/hosting/sites/new"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
New Site
</a>
<a href="/admin/hosting/sites"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
All Sites
</a>
<a href="/admin/hosting/clients"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
<span x-html="$icon('server', 'w-4 h-4 mr-2')"></span>
All Services
</a>
</div>
<!-- Sites by Status + Services by Type -->
<div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Sites by Status</h3>
<div class="space-y-3">
<template x-for="status in ['draft', 'poc_ready', 'proposal_sent', 'accepted', 'live', 'suspended', 'cancelled']" :key="status">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="status.replace('_', ' ')"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(status)"
x-text="stats.sites_by_status?.[status] || 0"></span>
</div>
</template>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Active Services</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Active</span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_services || 0"></span>
</div>
<template x-for="[type, count] in Object.entries(stats.services_by_type || {})" :key="type">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="type.replace('_', ' ')"></span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="count"></span>
</div>
</template>
</div>
<div class="mt-4 pt-4 border-t dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Monthly Revenue</span>
<span class="text-lg font-semibold text-green-600 dark:text-green-400"
x-text="'€' + ((stats.monthly_revenue_cents || 0) / 100).toFixed(2)"></span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingDashboard() {
return {
loading: true,
error: false,
stats: {},
async init() {
try {
const resp = await fetch('/api/admin/hosting/stats/dashboard');
if (!resp.ok) throw new Error('Failed to load');
this.stats = await resp.json();
} catch (e) {
this.error = true;
} finally {
this.loading = false;
}
},
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -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') }}
<div x-show="!loading && !error && site" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/admin/hosting/sites"
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a>
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="site.business_name"></h2>
<p class="text-sm text-gray-500" x-show="site.live_domain" x-text="site.live_domain"></p>
</div>
<span class="px-3 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(site.status)"
x-text="site.status.replace('_', ' ')"></span>
</div>
</div>
<!-- Lifecycle Actions -->
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<button x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
class="px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
Mark POC Ready
</button>
<button x-show="site.status === 'poc_ready'" @click="showProposalModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
Send Proposal
</button>
<button x-show="site.status === 'proposal_sent'" @click="showAcceptModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Accept Proposal
</button>
<button x-show="site.status === 'accepted'" @click="showGoLiveModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Go Live
</button>
<button x-show="site.status === 'live'" @click="doAction('suspend')"
class="px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Suspend
</button>
<button x-show="site.status === 'suspended'" @click="doAction('go-live', {domain: site.live_domain})"
class="px-3 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Reactivate
</button>
<button x-show="site.status !== 'cancelled' && site.status !== 'live'"
@click="doAction('cancel')"
class="px-3 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<a x-show="['poc_ready', 'proposal_sent'].includes(site.status)"
:href="'/hosting/sites/' + site.id + '/preview'" target="_blank"
class="ml-auto px-3 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900">
<span x-html="$icon('eye', 'w-4 h-4 inline mr-1')"></span>
Preview POC
</a>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-8">
<template x-for="tab in tabs" :key="tab.id">
<button @click="activeTab = tab.id"
:class="activeTab === tab.id ? 'border-teal-500 text-teal-600 dark:text-teal-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
x-text="tab.label"></button>
</template>
</nav>
</div>
<!-- Tab: Overview -->
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Name</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_name || '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Email</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_email || '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Phone</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_phone || '—'"></span>
</div>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Timeline</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Created</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.created_at ? new Date(site.created_at).toLocaleDateString() : '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Proposal Sent</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.proposal_sent_at ? new Date(site.proposal_sent_at).toLocaleDateString() : '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Accepted</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.proposal_accepted_at ? new Date(site.proposal_accepted_at).toLocaleDateString() : '—'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Went Live</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.went_live_at ? new Date(site.went_live_at).toLocaleDateString() : '—'"></span>
</div>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 md:col-span-2" x-show="site.proposal_notes || site.internal_notes">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Notes</h3>
<div x-show="site.proposal_notes" class="mb-3">
<p class="text-xs text-gray-500 uppercase mb-1">Proposal Notes</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.proposal_notes"></p>
</div>
<div x-show="site.internal_notes">
<p class="text-xs text-gray-500 uppercase mb-1">Internal Notes</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.internal_notes"></p>
</div>
</div>
</div>
<!-- Tab: Services -->
<div x-show="activeTab === 'services'" class="space-y-4">
<div class="flex justify-end">
<button @click="showServiceModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span>
Add Service
</button>
</div>
<template x-for="svc in site.client_services || []" :key="svc.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="svc.name"></span>
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="svc.service_type.replace('_', ' ')"></span>
</div>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="svc.status === 'active' ? 'bg-green-100 text-green-700' : svc.status === 'expired' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
x-text="svc.status"></span>
</div>
<div class="mt-2 flex gap-4 text-xs text-gray-500">
<span x-show="svc.price_cents" x-text="'€' + (svc.price_cents / 100).toFixed(2) + '/' + (svc.billing_period || 'month')"></span>
<span x-show="svc.domain_name" x-text="svc.domain_name"></span>
<span x-show="svc.expires_at" x-text="'Expires: ' + new Date(svc.expires_at).toLocaleDateString()"></span>
<span x-show="svc.mailbox_count" x-text="svc.mailbox_count + ' mailboxes'"></span>
</div>
</div>
</template>
<p x-show="!site.client_services?.length" class="text-sm text-gray-400 text-center py-8">No services yet</p>
</div>
<!-- Tab: Store -->
<div x-show="activeTab === 'store'" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Store Info</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Store ID</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.store_id"></span>
</div>
</div>
<div class="mt-4">
<a :href="'/admin/stores/' + site.store_id"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
Open in Store Admin
</a>
</div>
</div>
</div>
<!-- Send Proposal Modal --> {# noqa: FE-004 #}
<div x-show="showProposalModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showProposalModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showProposalModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Send Proposal</h3>
<button @click="showProposalModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Proposal Notes</label>
<textarea x-model="proposalNotes" rows="4"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showProposalModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="sendProposal()"
class="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">Send</button>
</footer>
</div>
</div>
<!-- Accept Proposal Modal --> {# noqa: FE-004 #}
<div x-show="showAcceptModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showAcceptModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showAcceptModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Accept Proposal</h3>
<button @click="showAcceptModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Existing Merchant ID (leave empty to create new)</label>
<input type="number" x-model="acceptMerchantId" placeholder="Optional"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showAcceptModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="acceptProposal()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">Accept</button>
</footer>
</div>
</div>
<!-- Go Live Modal --> {# noqa: FE-004 #}
<div x-show="showGoLiveModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showGoLiveModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showGoLiveModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Go Live</h3>
<button @click="showGoLiveModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Domain *</label>
<input type="text" x-model="goLiveDomain" placeholder="example.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showGoLiveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="goLive()"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">Go Live</button>
</footer>
</div>
</div>
<!-- Add Service Modal --> {# noqa: FE-004 #}
<div x-show="showServiceModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showServiceModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showServiceModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add Service</h3>
<button @click="showServiceModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label>
<select x-model="newService.service_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="domain">Domain</option>
<option value="email">Email</option>
<option value="ssl">SSL</option>
<option value="hosting">Hosting</option>
<option value="website_maintenance">Website Maintenance</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label>
<input type="text" x-model="newService.name" placeholder="e.g., acme.lu domain"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Price (cents)</label>
{{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }}
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Billing</label>
<select x-model="newService.billing_period"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="monthly">Monthly</option>
<option value="annual">Annual</option>
<option value="one_time">One-time</option>
</select>
</div>
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showServiceModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="addService()"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">Add</button>
</footer>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSiteDetail(siteId) {
return {
loading: true,
error: false,
site: null,
activeTab: 'overview',
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'services', label: 'Services' },
{ id: 'store', label: 'Store' },
],
showProposalModal: false,
showAcceptModal: false,
showGoLiveModal: false,
showServiceModal: false,
proposalNotes: '',
acceptMerchantId: '',
goLiveDomain: '',
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
async init() { await this.loadSite(); },
async loadSite() {
this.loading = true;
try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId);
if (!resp.ok) throw new Error('Failed to load');
this.site = await resp.json();
} catch (e) { this.error = true; }
finally { this.loading = false; }
},
async doAction(action, body = {}) {
try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Action failed'); return; }
await this.loadSite();
} catch (e) { alert('Action failed'); }
},
async sendProposal() {
await this.doAction('send-proposal', { notes: this.proposalNotes });
this.showProposalModal = false;
},
async acceptProposal() {
const body = {};
if (this.acceptMerchantId) body.merchant_id = parseInt(this.acceptMerchantId);
await this.doAction('accept', body);
this.showAcceptModal = false;
},
async goLive() {
await this.doAction('go-live', { domain: this.goLiveDomain });
this.showGoLiveModal = false;
},
async addService() {
try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newService),
});
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Failed'); return; }
this.showServiceModal = false;
this.newService = { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' };
await this.loadSite();
} catch (e) { alert('Failed to add service'); }
},
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -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') }}
<div class="max-w-2xl">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Business Name *</label>
<input type="text" x-model="form.business_name" placeholder="Acme Luxembourg SARL"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-teal-400 focus:ring-teal-300">
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Name</label>
<input type="text" x-model="form.contact_name" placeholder="Jean Dupont"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Email</label>
<input type="email" x-model="form.contact_email" placeholder="contact@acme.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Phone</label>
<input type="tel" x-model="form.contact_phone" placeholder="+352 ..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Internal Notes</label>
<textarea x-model="form.internal_notes" rows="3" placeholder="Any internal notes..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<!-- Prospect Selector -->
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Create from Prospect (optional)</label>
<div class="flex mt-1 space-x-2">
<input type="number" x-model="prospectId" placeholder="Prospect ID"
class="flex-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<button @click="createFromProspect()"
:disabled="!prospectId || creating"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700 disabled:opacity-50">
Create from Prospect
</button>
</div>
</div>
</div>
<div class="flex justify-end mt-8 space-x-3">
<a href="/admin/hosting/sites"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</a>
<button @click="createSite()"
:disabled="!form.business_name || creating"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700 disabled:opacity-50">
<span x-show="!creating">Create Site</span>
<span x-show="creating">Creating...</span>
</button>
</div>
<div x-show="errorMsg" class="mt-4 p-3 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-900 dark:text-red-300" x-text="errorMsg"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSiteNew() {
return {
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
prospectId: '',
creating: false,
errorMsg: '',
async createSite() {
this.creating = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/admin/hosting/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
});
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
const site = await resp.json();
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create site'; }
finally { this.creating = false; }
},
async createFromProspect() {
this.creating = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/admin/hosting/sites/from-prospect/' + this.prospectId, { method: 'POST' });
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
const site = await resp.json();
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create from prospect'; }
finally { this.creating = false; }
},
};
}
</script>
{% endblock %}

View File

@@ -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') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label>
<input type="text" x-model="search" @input.debounce.300ms="loadSites()"
placeholder="Business name, email, domain..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-teal-400 focus:ring-teal-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadSites()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="draft">Draft</option>
<option value="poc_ready">POC Ready</option>
<option value="proposal_sent">Proposal Sent</option>
<option value="accepted">Accepted</option>
<option value="live">Live</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="flex items-end">
<a href="/admin/prospecting/prospects"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900 dark:hover:bg-teal-800">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Create from Prospect
</a>
</div>
</div>
</div>
{{ loading_state('Loading sites...') }}
{{ error_state('Error loading sites') }}
<!-- Sites Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Domain</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="s in sites" :key="s.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="s.business_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="s.contact_name || ''"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(s.status)"
x-text="s.status.replace('_', ' ')"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="s.contact_email || '—'" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="s.live_domain || '—'" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="new Date(s.created_at).toLocaleDateString()" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm">
<a :href="'/admin/hosting/sites/' + s.id"
class="text-teal-600 hover:text-teal-900 dark:text-teal-400 dark:hover:text-teal-300">
View
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ table_empty('No hosted sites found') }}
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSitesList() {
return {
loading: true,
error: false,
sites: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
search: '',
filterStatus: '',
async init() { await this.loadSites(); },
async loadSites() {
this.loading = true;
this.error = false;
try {
const params = new URLSearchParams({ page: this.page, per_page: this.perPage });
if (this.search) params.set('search', this.search);
if (this.filterStatus) params.set('status', this.filterStatus);
const resp = await fetch('/api/admin/hosting/sites?' + params);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
this.sites = data.items;
this.total = data.total;
this.pages = data.pages;
} catch (e) { this.error = true; }
finally { this.loading = false; }
},
goToPage(p) { this.page = p; this.loadSites(); },
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site.business_name }} - Preview by HostWizard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.hw-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background: linear-gradient(135deg, #0D9488, #14B8A6);
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
.hw-banner-logo { font-weight: 700; font-size: 16px; }
.hw-banner-text { opacity: 0.9; }
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
.hw-banner-link {
color: white;
text-decoration: none;
padding: 6px 16px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 6px;
font-size: 13px;
transition: background 0.2s;
}
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
.hw-iframe-container {
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
}
.hw-iframe-container iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<div class="hw-banner">
<div class="hw-banner-left">
<span class="hw-banner-logo">HostWizard</span>
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
</div>
<div class="hw-banner-right">
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
</div>
</div>
<div class="hw-iframe-container">
<iframe src="{{ store_url }}" title="Site preview"></iframe>
</div>
</body>
</html>

View File

@@ -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

View File

@@ -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 |
|---|---|---|

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 ---

View File

@@ -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