diff --git a/alembic.ini b/alembic.ini index 35e367b1..32336f7c 100644 --- a/alembic.ini +++ b/alembic.ini @@ -3,7 +3,7 @@ script_location = alembic prepend_sys_path = . version_path_separator = space -version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/tenancy/migrations/versions +version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/hosting/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions app/modules/prospecting/migrations/versions app/modules/tenancy/migrations/versions # This will be overridden by alembic\env.py using settings.database_url sqlalchemy.url = # for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db diff --git a/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py b/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py index 57046779..a5c78209 100644 --- a/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py +++ b/app/modules/cms/migrations/versions/cms_002_add_title_content_translations.py @@ -1,7 +1,7 @@ """add title_translations and content_translations to content_pages Revision ID: cms_002 -Revises: cms_001 +Revises: z_unique_subdomain_domain Create Date: 2026-03-02 """ @@ -10,7 +10,7 @@ import sqlalchemy as sa from alembic import op revision = "cms_002" -down_revision = "cms_001" +down_revision = "z_unique_subdomain_domain" branch_labels = None depends_on = None diff --git a/app/modules/hosting/__init__.py b/app/modules/hosting/__init__.py new file mode 100644 index 00000000..e4b43937 --- /dev/null +++ b/app/modules/hosting/__init__.py @@ -0,0 +1,36 @@ +# app/modules/hosting/__init__.py +""" +Hosting Module - Web hosting, domains, email, and website building. + +This is a self-contained module providing: +- POC website lifecycle management (draft → live pipeline) +- Client service tracking (domains, email, SSL, hosting, maintenance) +- Integration with prospecting module for prospect → merchant conversion +- Dashboard with KPIs, status charts, and renewal alerts + +Module Structure: +- models/ - Database models (hosted_site, client_service) +- services/ - Business logic (lifecycle, service management, stats) +- schemas/ - Pydantic DTOs +- routes/ - API and page routes (admin + public POC viewer) +- tasks/ - Celery background tasks for expiry checks +- exceptions.py - Module-specific exceptions +""" + + +def __getattr__(name: str): + """Lazy import module components to avoid circular imports.""" + if name == "hosting_module": + from app.modules.hosting.definition import hosting_module + + return hosting_module + if name == "get_hosting_module_with_routers": + from app.modules.hosting.definition import ( + get_hosting_module_with_routers, + ) + + return get_hosting_module_with_routers + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = ["hosting_module", "get_hosting_module_with_routers"] diff --git a/app/modules/hosting/config.py b/app/modules/hosting/config.py new file mode 100644 index 00000000..409b1bea --- /dev/null +++ b/app/modules/hosting/config.py @@ -0,0 +1,31 @@ +# app/modules/hosting/config.py +""" +Module configuration. + +Environment-based configuration using Pydantic Settings. +Settings are loaded from environment variables with HOSTING_ prefix. + +Example: + HOSTING_DEFAULT_BILLING_PERIOD=annual +""" +from pydantic_settings import BaseSettings + + +class ModuleConfig(BaseSettings): + """Configuration for hosting module.""" + + # Default billing period for new services + default_billing_period: str = "monthly" + + # Days before expiry to trigger renewal alerts + renewal_alert_days: int = 30 + + # Default currency for pricing + default_currency: str = "EUR" + + model_config = {"env_prefix": "HOSTING_"} + + +# Export for auto-discovery +config_class = ModuleConfig +config = ModuleConfig() diff --git a/app/modules/hosting/definition.py b/app/modules/hosting/definition.py new file mode 100644 index 00000000..1164e040 --- /dev/null +++ b/app/modules/hosting/definition.py @@ -0,0 +1,132 @@ +# app/modules/hosting/definition.py +""" +Hosting module definition. + +Web hosting, domains, email, and website building for Luxembourg businesses. +Manages POC → live website pipeline and operational service tracking. +Admin-only module for superadmin and platform admin users. +""" + +from app.modules.base import ( + MenuItemDefinition, + MenuSectionDefinition, + ModuleDefinition, + PermissionDefinition, +) +from app.modules.enums import FrontendType + + +def _get_admin_api_router(): + """Lazy import of admin API router to avoid circular imports.""" + from app.modules.hosting.routes.api.admin import router + + return router + + +def _get_admin_page_router(): + """Lazy import of admin page router to avoid circular imports.""" + from app.modules.hosting.routes.pages.admin import router + + return router + + +def _get_public_page_router(): + """Lazy import of public page router to avoid circular imports.""" + from app.modules.hosting.routes.pages.public import router + + return router + + +hosting_module = ModuleDefinition( + code="hosting", + name="Hosting", + description="Web hosting, domains, email, and website building for Luxembourg businesses.", + version="1.0.0", + is_core=False, + is_self_contained=True, + requires=["prospecting"], + permissions=[ + PermissionDefinition( + id="hosting.view", + label_key="hosting.permissions.view", + description_key="hosting.permissions.view_desc", + category="hosting", + ), + PermissionDefinition( + id="hosting.manage", + label_key="hosting.permissions.manage", + description_key="hosting.permissions.manage_desc", + category="hosting", + ), + ], + features=[ + "hosting", + "domains", + "email", + "ssl", + "poc_sites", + ], + menu_items={ + FrontendType.ADMIN: [ + "hosting-dashboard", + "hosting-sites", + "hosting-clients", + ], + }, + menus={ + FrontendType.ADMIN: [ + MenuSectionDefinition( + id="hosting", + label_key="hosting.menu.hosting", + icon="globe", + order=65, + items=[ + MenuItemDefinition( + id="hosting-dashboard", + label_key="hosting.menu.dashboard", + icon="chart-bar", + route="/admin/hosting", + order=1, + ), + MenuItemDefinition( + id="hosting-sites", + label_key="hosting.menu.sites", + icon="globe", + route="/admin/hosting/sites", + order=5, + ), + MenuItemDefinition( + id="hosting-clients", + label_key="hosting.menu.clients", + icon="server", + route="/admin/hosting/clients", + order=10, + ), + ], + ), + ], + }, + migrations_path="migrations", + services_path="app.modules.hosting.services", + models_path="app.modules.hosting.models", + schemas_path="app.modules.hosting.schemas", + exceptions_path="app.modules.hosting.exceptions", + templates_path="templates", + locales_path="locales", + tasks_path="app.modules.hosting.tasks", +) + + +def get_hosting_module_with_routers() -> ModuleDefinition: + """ + Get hosting module with routers attached. + + Attaches routers lazily to avoid circular imports during module initialization. + """ + hosting_module.admin_api_router = _get_admin_api_router() + hosting_module.admin_page_router = _get_admin_page_router() + hosting_module.public_page_router = _get_public_page_router() + return hosting_module + + +__all__ = ["hosting_module", "get_hosting_module_with_routers"] diff --git a/app/modules/hosting/exceptions.py b/app/modules/hosting/exceptions.py new file mode 100644 index 00000000..f2ba3a15 --- /dev/null +++ b/app/modules/hosting/exceptions.py @@ -0,0 +1,57 @@ +# app/modules/hosting/exceptions.py +""" +Hosting module exceptions. +""" + +from app.exceptions.base import ( + BusinessLogicException, + ResourceNotFoundException, +) + + +class HostedSiteNotFoundException(ResourceNotFoundException): # noqa: MOD025 + """Raised when a hosted site is not found.""" + + def __init__(self, identifier: str): + super().__init__( + resource_type="HostedSite", + identifier=identifier, + ) + + +class ClientServiceNotFoundException(ResourceNotFoundException): # noqa: MOD025 + """Raised when a client service is not found.""" + + def __init__(self, identifier: str): + super().__init__( + resource_type="ClientService", + identifier=identifier, + ) + + +class DuplicateSlugException(BusinessLogicException): # noqa: MOD025 + """Raised when trying to create a site with a slug that already exists.""" + + def __init__(self, slug: str): + super().__init__( + message=f"A hosted site with slug '{slug}' already exists", + error_code="DUPLICATE_SLUG", + ) + + +class InvalidStatusTransitionException(BusinessLogicException): # noqa: MOD025 + """Raised when attempting an invalid status transition.""" + + def __init__(self, current_status: str, target_status: str): + super().__init__( + message=f"Cannot transition from '{current_status}' to '{target_status}'", + error_code="INVALID_STATUS_TRANSITION", + ) + + +__all__ = [ + "HostedSiteNotFoundException", + "ClientServiceNotFoundException", + "DuplicateSlugException", + "InvalidStatusTransitionException", +] diff --git a/app/modules/hosting/locales/de.json b/app/modules/hosting/locales/de.json new file mode 100644 index 00000000..15056fb3 --- /dev/null +++ b/app/modules/hosting/locales/de.json @@ -0,0 +1,22 @@ +{ + "hosting": { + "page_title": "Hosting", + "dashboard_title": "Hosting Dashboard", + "sites": "Webseiten", + "clients": "Kundendienste", + "loading": "Laden...", + "error_loading": "Fehler beim Laden der Daten" + }, + "permissions": { + "view": "Hosting anzeigen", + "view_desc": "Zugriff auf gehostete Webseiten, Dienste und Statistiken", + "manage": "Hosting verwalten", + "manage_desc": "Gehostete Webseiten und Dienste erstellen, bearbeiten und verwalten" + }, + "menu": { + "hosting": "Hosting", + "dashboard": "Dashboard", + "sites": "Webseiten", + "clients": "Kundendienste" + } +} diff --git a/app/modules/hosting/locales/en.json b/app/modules/hosting/locales/en.json new file mode 100644 index 00000000..d6813dbe --- /dev/null +++ b/app/modules/hosting/locales/en.json @@ -0,0 +1,22 @@ +{ + "hosting": { + "page_title": "Hosting", + "dashboard_title": "Hosting Dashboard", + "sites": "Sites", + "clients": "Client Services", + "loading": "Loading...", + "error_loading": "Failed to load data" + }, + "permissions": { + "view": "View Hosting", + "view_desc": "View hosted sites, services, and statistics", + "manage": "Manage Hosting", + "manage_desc": "Create, edit, and manage hosted sites and services" + }, + "menu": { + "hosting": "Hosting", + "dashboard": "Dashboard", + "sites": "Sites", + "clients": "Client Services" + } +} diff --git a/app/modules/hosting/locales/fr.json b/app/modules/hosting/locales/fr.json new file mode 100644 index 00000000..3c1fdb94 --- /dev/null +++ b/app/modules/hosting/locales/fr.json @@ -0,0 +1,22 @@ +{ + "hosting": { + "page_title": "Hébergement", + "dashboard_title": "Tableau de bord Hébergement", + "sites": "Sites", + "clients": "Services clients", + "loading": "Chargement...", + "error_loading": "Erreur lors du chargement des données" + }, + "permissions": { + "view": "Voir l'hébergement", + "view_desc": "Voir les sites hébergés, services et statistiques", + "manage": "Gérer l'hébergement", + "manage_desc": "Créer, modifier et gérer les sites hébergés et services" + }, + "menu": { + "hosting": "Hébergement", + "dashboard": "Tableau de bord", + "sites": "Sites", + "clients": "Services clients" + } +} diff --git a/app/modules/hosting/locales/lb.json b/app/modules/hosting/locales/lb.json new file mode 100644 index 00000000..3efbae1f --- /dev/null +++ b/app/modules/hosting/locales/lb.json @@ -0,0 +1,22 @@ +{ + "hosting": { + "page_title": "Hosting", + "dashboard_title": "Hosting Dashboard", + "sites": "Websäiten", + "clients": "Client Servicer", + "loading": "Lueden...", + "error_loading": "Feeler beim Luede vun den Donnéeën" + }, + "permissions": { + "view": "Hosting kucken", + "view_desc": "Zougang zu gehosten Websäiten, Servicer a Statistiken", + "manage": "Hosting geréieren", + "manage_desc": "Gehost Websäiten a Servicer erstellen, änneren a geréieren" + }, + "menu": { + "hosting": "Hosting", + "dashboard": "Dashboard", + "sites": "Websäiten", + "clients": "Client Servicer" + } +} diff --git a/app/modules/hosting/migrations/__init__.py b/app/modules/hosting/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/hosting/migrations/versions/__init__.py b/app/modules/hosting/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/hosting/migrations/versions/hosting_001_initial.py b/app/modules/hosting/migrations/versions/hosting_001_initial.py new file mode 100644 index 00000000..753fa148 --- /dev/null +++ b/app/modules/hosting/migrations/versions/hosting_001_initial.py @@ -0,0 +1,125 @@ +"""hosting: initial tables for hosted sites and client services + +Revision ID: hosting_001 +Revises: prospecting_001 +Create Date: 2026-03-03 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "hosting_001" +down_revision = "prospecting_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- hosted_sites --- + op.create_table( + "hosted_sites", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "store_id", + sa.Integer(), + sa.ForeignKey("stores.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column( + "prospect_id", + sa.Integer(), + sa.ForeignKey("prospects.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="draft", + ), + sa.Column("business_name", sa.String(255), nullable=False), + sa.Column("contact_name", sa.String(255), nullable=True), + sa.Column("contact_email", sa.String(255), nullable=True), + sa.Column("contact_phone", sa.String(50), nullable=True), + sa.Column("proposal_sent_at", sa.DateTime(), nullable=True), + sa.Column("proposal_accepted_at", sa.DateTime(), nullable=True), + sa.Column("went_live_at", sa.DateTime(), nullable=True), + sa.Column("proposal_notes", sa.Text(), nullable=True), + sa.Column("live_domain", sa.String(255), nullable=True, unique=True), + sa.Column("internal_notes", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_hosted_sites_status", "hosted_sites", ["status"]) + op.create_index("ix_hosted_sites_prospect_id", "hosted_sites", ["prospect_id"]) + + # --- client_services --- + op.create_table( + "client_services", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "hosted_site_id", + sa.Integer(), + sa.ForeignKey("hosted_sites.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("service_type", sa.String(30), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="pending", + ), + sa.Column("billing_period", sa.String(20), nullable=True), + sa.Column("price_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"), + sa.Column( + "addon_product_id", + sa.Integer(), + sa.ForeignKey("addon_products.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("domain_name", sa.String(255), nullable=True), + sa.Column("registrar", sa.String(100), nullable=True), + sa.Column("mailbox_count", sa.Integer(), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("period_start", sa.DateTime(), nullable=True), + sa.Column("period_end", sa.DateTime(), nullable=True), + sa.Column("auto_renew", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_client_services_service_type", "client_services", ["service_type"]) + op.create_index("ix_client_services_status", "client_services", ["status"]) + op.create_index("ix_client_services_expires_at", "client_services", ["expires_at"]) + + +def downgrade() -> None: + op.drop_table("client_services") + op.drop_table("hosted_sites") diff --git a/app/modules/hosting/models/__init__.py b/app/modules/hosting/models/__init__.py new file mode 100644 index 00000000..2a8c4541 --- /dev/null +++ b/app/modules/hosting/models/__init__.py @@ -0,0 +1,20 @@ +# app/modules/hosting/models/__init__.py +from app.modules.hosting.models.client_service import ( + BillingPeriod, + ClientService, + ClientServiceStatus, + ServiceType, +) +from app.modules.hosting.models.hosted_site import ( + HostedSite, + HostedSiteStatus, +) + +__all__ = [ + "HostedSite", + "HostedSiteStatus", + "ClientService", + "ServiceType", + "ClientServiceStatus", + "BillingPeriod", +] diff --git a/app/modules/hosting/models/client_service.py b/app/modules/hosting/models/client_service.py new file mode 100644 index 00000000..366360ce --- /dev/null +++ b/app/modules/hosting/models/client_service.py @@ -0,0 +1,97 @@ +# app/modules/hosting/models/client_service.py +""" +ClientService model - operational tracking for services. + +Complements billing StoreAddOn with operational details like domain expiry, +registrar info, and mailbox counts. +""" + +import enum + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class ServiceType(str, enum.Enum): + DOMAIN = "domain" + EMAIL = "email" + SSL = "ssl" + HOSTING = "hosting" + WEBSITE_MAINTENANCE = "website_maintenance" + + +class ClientServiceStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + SUSPENDED = "suspended" + EXPIRED = "expired" + CANCELLED = "cancelled" + + +class BillingPeriod(str, enum.Enum): + MONTHLY = "monthly" + ANNUAL = "annual" + ONE_TIME = "one_time" + + +class ClientService(Base, TimestampMixin): + """Represents an operational service for a hosted site.""" + + __tablename__ = "client_services" + + id = Column(Integer, primary_key=True, index=True) + hosted_site_id = Column( + Integer, + ForeignKey("hosted_sites.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + service_type = Column(Enum(ServiceType), nullable=False) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + status = Column( + Enum(ClientServiceStatus), + nullable=False, + default=ClientServiceStatus.PENDING, + ) + + # Billing + billing_period = Column(Enum(BillingPeriod), nullable=True) + price_cents = Column(Integer, nullable=True) + currency = Column(String(3), nullable=False, default="EUR") + addon_product_id = Column( + Integer, + ForeignKey("addon_products.id", ondelete="SET NULL"), + nullable=True, + ) + + # Domain-specific + domain_name = Column(String(255), nullable=True) + registrar = Column(String(100), nullable=True) + + # Email-specific + mailbox_count = Column(Integer, nullable=True) + + # Expiry and renewal + expires_at = Column(DateTime, nullable=True) + period_start = Column(DateTime, nullable=True) + period_end = Column(DateTime, nullable=True) + auto_renew = Column(Boolean, nullable=False, default=True) + + # Notes + notes = Column(Text, nullable=True) + + # Relationships + hosted_site = relationship("HostedSite", back_populates="client_services") diff --git a/app/modules/hosting/models/hosted_site.py b/app/modules/hosting/models/hosted_site.py new file mode 100644 index 00000000..3f5e3767 --- /dev/null +++ b/app/modules/hosting/models/hosted_site.py @@ -0,0 +1,81 @@ +# app/modules/hosting/models/hosted_site.py +""" +HostedSite model - links a Store to a Prospect and tracks the POC → live lifecycle. + +Lifecycle: draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled +""" + +import enum + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.core.database import Base +from models.database.base import TimestampMixin + + +class HostedSiteStatus(str, enum.Enum): + DRAFT = "draft" + POC_READY = "poc_ready" + PROPOSAL_SENT = "proposal_sent" + ACCEPTED = "accepted" + LIVE = "live" + SUSPENDED = "suspended" + CANCELLED = "cancelled" + + +class HostedSite(Base, TimestampMixin): + """Represents a hosted website linking a Store to a Prospect.""" + + __tablename__ = "hosted_sites" + + id = Column(Integer, primary_key=True, index=True) + store_id = Column( + Integer, + ForeignKey("stores.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + prospect_id = Column( + Integer, + ForeignKey("prospects.id", ondelete="SET NULL"), + nullable=True, + ) + status = Column( + Enum(HostedSiteStatus), + nullable=False, + default=HostedSiteStatus.DRAFT, + ) + + # Denormalized for dashboard display + business_name = Column(String(255), nullable=False) + contact_name = Column(String(255), nullable=True) + contact_email = Column(String(255), nullable=True) + contact_phone = Column(String(50), nullable=True) + + # Lifecycle timestamps + proposal_sent_at = Column(DateTime, nullable=True) + proposal_accepted_at = Column(DateTime, nullable=True) + went_live_at = Column(DateTime, nullable=True) + + # Proposal + proposal_notes = Column(Text, nullable=True) + + # Denormalized from StoreDomain + live_domain = Column(String(255), nullable=True, unique=True) + + # Internal notes + internal_notes = Column(Text, nullable=True) + + # Relationships + store = relationship("Store", backref="hosted_site", uselist=False) + prospect = relationship("Prospect", backref="hosted_sites") + client_services = relationship( + "ClientService", + back_populates="hosted_site", + cascade="all, delete-orphan", + ) + + @property + def display_name(self) -> str: + return self.business_name or f"Site #{self.id}" diff --git a/app/modules/hosting/routes/__init__.py b/app/modules/hosting/routes/__init__.py new file mode 100644 index 00000000..11187237 --- /dev/null +++ b/app/modules/hosting/routes/__init__.py @@ -0,0 +1 @@ +# app/modules/hosting/routes/__init__.py diff --git a/app/modules/hosting/routes/api/__init__.py b/app/modules/hosting/routes/api/__init__.py new file mode 100644 index 00000000..49d677da --- /dev/null +++ b/app/modules/hosting/routes/api/__init__.py @@ -0,0 +1 @@ +# app/modules/hosting/routes/api/__init__.py diff --git a/app/modules/hosting/routes/api/admin.py b/app/modules/hosting/routes/api/admin.py new file mode 100644 index 00000000..07d93fa9 --- /dev/null +++ b/app/modules/hosting/routes/api/admin.py @@ -0,0 +1,21 @@ +# app/modules/hosting/routes/api/admin.py +""" +Hosting module admin API routes. + +Aggregates all admin hosting routes: +- /sites/* - Hosted site CRUD and lifecycle +- /sites/{id}/services/* - Client service management +- /stats/* - Dashboard statistics +""" + +from fastapi import APIRouter + +from .admin_services import router as admin_services_router +from .admin_sites import router as admin_sites_router +from .admin_stats import router as admin_stats_router + +router = APIRouter() + +router.include_router(admin_sites_router, tags=["hosting-sites"]) +router.include_router(admin_services_router, tags=["hosting-services"]) +router.include_router(admin_stats_router, tags=["hosting-stats"]) diff --git a/app/modules/hosting/routes/api/admin_services.py b/app/modules/hosting/routes/api/admin_services.py index c920eddc..fcb32593 100644 --- a/app/modules/hosting/routes/api/admin_services.py +++ b/app/modules/hosting/routes/api/admin_services.py @@ -12,6 +12,7 @@ from app.api.deps import get_current_admin_api from app.core.database import get_db from app.modules.hosting.schemas.client_service import ( ClientServiceCreate, + ClientServiceDeleteResponse, ClientServiceResponse, ClientServiceUpdate, ) @@ -59,7 +60,7 @@ def update_service( return service -@router.delete("/{service_id}") +@router.delete("/{service_id}", response_model=ClientServiceDeleteResponse) def delete_service( site_id: int = Path(...), service_id: int = Path(...), @@ -69,4 +70,4 @@ def delete_service( """Delete a client service.""" client_service_service.delete(db, service_id) db.commit() - return {"message": "Service deleted"} # noqa: API001 + return ClientServiceDeleteResponse(message="Service deleted") diff --git a/app/modules/hosting/routes/api/admin_sites.py b/app/modules/hosting/routes/api/admin_sites.py new file mode 100644 index 00000000..e08ba1fa --- /dev/null +++ b/app/modules/hosting/routes/api/admin_sites.py @@ -0,0 +1,210 @@ +# app/modules/hosting/routes/api/admin_sites.py +""" +Admin API routes for hosted site management. +""" + +import logging +from math import ceil + +from fastapi import APIRouter, Depends, Path, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.hosting.schemas.hosted_site import ( + AcceptProposalRequest, + GoLiveRequest, + HostedSiteCreate, + HostedSiteDeleteResponse, + HostedSiteDetailResponse, + HostedSiteListResponse, + HostedSiteResponse, + HostedSiteUpdate, + SendProposalRequest, +) +from app.modules.hosting.services.hosted_site_service import hosted_site_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/sites") +logger = logging.getLogger(__name__) + + +def _to_response(site) -> HostedSiteResponse: + """Convert a hosted site model to response schema.""" + return HostedSiteResponse( + id=site.id, + store_id=site.store_id, + prospect_id=site.prospect_id, + status=site.status.value if hasattr(site.status, "value") else str(site.status), + business_name=site.business_name, + contact_name=site.contact_name, + contact_email=site.contact_email, + contact_phone=site.contact_phone, + proposal_sent_at=site.proposal_sent_at, + proposal_accepted_at=site.proposal_accepted_at, + went_live_at=site.went_live_at, + proposal_notes=site.proposal_notes, + live_domain=site.live_domain, + internal_notes=site.internal_notes, + created_at=site.created_at, + updated_at=site.updated_at, + ) + + +@router.get("", response_model=HostedSiteListResponse) +def list_sites( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + search: str | None = Query(None), + status: str | None = Query(None), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """List hosted sites with filters and pagination.""" + sites, total = hosted_site_service.get_all( + db, page=page, per_page=per_page, search=search, status=status, + ) + return HostedSiteListResponse( + items=[_to_response(s) for s in sites], + total=total, + page=page, + per_page=per_page, + pages=ceil(total / per_page) if per_page else 0, + ) + + +@router.get("/{site_id}", response_model=HostedSiteDetailResponse) +def get_site( + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get full hosted site detail with services.""" + site = hosted_site_service.get_by_id(db, site_id) + return site + + +@router.post("", response_model=HostedSiteResponse) +def create_site( + data: HostedSiteCreate, + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Create a new hosted site (auto-creates Store).""" + site = hosted_site_service.create(db, data.model_dump(exclude_none=True)) + db.commit() + return _to_response(site) + + +@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse) +def create_from_prospect( + prospect_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Create a hosted site pre-filled from prospect data.""" + site = hosted_site_service.create_from_prospect(db, prospect_id) + db.commit() + return _to_response(site) + + +@router.put("/{site_id}", response_model=HostedSiteResponse) +def update_site( + data: HostedSiteUpdate, + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Update a hosted site.""" + site = hosted_site_service.update(db, site_id, data.model_dump(exclude_none=True)) + db.commit() + return _to_response(site) + + +@router.delete("/{site_id}", response_model=HostedSiteDeleteResponse) +def delete_site( + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Delete a hosted site.""" + hosted_site_service.delete(db, site_id) + db.commit() + return HostedSiteDeleteResponse(message="Hosted site deleted") + + +# ── Lifecycle endpoints ──────────────────────────────────────────────── + +@router.post("/{site_id}/mark-poc-ready", response_model=HostedSiteResponse) +def mark_poc_ready( + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Mark site as POC ready.""" + site = hosted_site_service.mark_poc_ready(db, site_id) + db.commit() + return _to_response(site) + + +@router.post("/{site_id}/send-proposal", response_model=HostedSiteResponse) +def send_proposal( + data: SendProposalRequest, + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Send proposal to prospect.""" + site = hosted_site_service.send_proposal(db, site_id, notes=data.notes) + db.commit() + return _to_response(site) + + +@router.post("/{site_id}/accept", response_model=HostedSiteResponse) +def accept_proposal( + data: AcceptProposalRequest, + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Accept proposal: create/link merchant, create subscription.""" + site = hosted_site_service.accept_proposal(db, site_id, merchant_id=data.merchant_id) + db.commit() + return _to_response(site) + + +@router.post("/{site_id}/go-live", response_model=HostedSiteResponse) +def go_live( + data: GoLiveRequest, + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Go live with a domain.""" + site = hosted_site_service.go_live(db, site_id, domain=data.domain) + db.commit() + return _to_response(site) + + +@router.post("/{site_id}/suspend", response_model=HostedSiteResponse) +def suspend_site( + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Suspend a site.""" + site = hosted_site_service.suspend(db, site_id) + db.commit() + return _to_response(site) + + +@router.post("/{site_id}/cancel", response_model=HostedSiteResponse) +def cancel_site( + site_id: int = Path(...), + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Cancel a site.""" + site = hosted_site_service.cancel(db, site_id) + db.commit() + return _to_response(site) diff --git a/app/modules/hosting/routes/api/admin_stats.py b/app/modules/hosting/routes/api/admin_stats.py new file mode 100644 index 00000000..8fd4a096 --- /dev/null +++ b/app/modules/hosting/routes/api/admin_stats.py @@ -0,0 +1,26 @@ +# app/modules/hosting/routes/api/admin_stats.py +""" +Admin API routes for hosting dashboard statistics. +""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.modules.hosting.services.stats_service import stats_service +from app.modules.tenancy.schemas.auth import UserContext + +router = APIRouter(prefix="/stats") +logger = logging.getLogger(__name__) + + +@router.get("/dashboard") +def get_dashboard_stats( + db: Session = Depends(get_db), + current_admin: UserContext = Depends(get_current_admin_api), +): + """Get dashboard statistics for the hosting module.""" + return stats_service.get_dashboard_stats(db) diff --git a/app/modules/hosting/routes/pages/__init__.py b/app/modules/hosting/routes/pages/__init__.py new file mode 100644 index 00000000..78ae794c --- /dev/null +++ b/app/modules/hosting/routes/pages/__init__.py @@ -0,0 +1 @@ +# app/modules/hosting/routes/pages/__init__.py diff --git a/app/modules/hosting/routes/pages/admin.py b/app/modules/hosting/routes/pages/admin.py new file mode 100644 index 00000000..894882c1 --- /dev/null +++ b/app/modules/hosting/routes/pages/admin.py @@ -0,0 +1,95 @@ +# app/modules/hosting/routes/pages/admin.py +""" +Hosting Admin Page Routes (HTML rendering). + +Admin pages for hosted site management: +- Dashboard - Overview with KPIs and charts +- Sites - Hosted site list with filters +- Site Detail - Single site view with tabs (overview, services, store link) +- Site New - Create new hosted site form +- Clients - All services overview with expiry warnings +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_menu_access +from app.modules.core.utils.page_context import get_admin_context +from app.modules.enums import FrontendType +from app.modules.tenancy.models import User +from app.templates_config import templates + +router = APIRouter() + + +@router.get("/hosting", response_class=HTMLResponse, include_in_schema=False) +async def admin_hosting_dashboard( + request: Request, + current_user: User = Depends(require_menu_access("hosting-dashboard", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render hosting dashboard page.""" + return templates.TemplateResponse( + "hosting/admin/dashboard.html", + get_admin_context(request, db, current_user), + ) + + +@router.get("/hosting/sites", response_class=HTMLResponse, include_in_schema=False) +async def admin_hosting_sites( + request: Request, + current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render hosted sites list page.""" + return templates.TemplateResponse( + "hosting/admin/sites.html", + get_admin_context(request, db, current_user), + ) + + +@router.get("/hosting/sites/new", response_class=HTMLResponse, include_in_schema=False) +async def admin_hosting_site_new( + request: Request, + current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render new hosted site form.""" + return templates.TemplateResponse( + "hosting/admin/site-new.html", + get_admin_context(request, db, current_user), + ) + + +@router.get( + "/hosting/sites/{site_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def admin_hosting_site_detail( + request: Request, + site_id: int = Path(..., description="Hosted Site ID"), + current_user: User = Depends(require_menu_access("hosting-sites", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render hosted site detail page.""" + context = get_admin_context(request, db, current_user) + context["site_id"] = site_id + return templates.TemplateResponse( + "hosting/admin/site-detail.html", + context, + ) + + +@router.get("/hosting/clients", response_class=HTMLResponse, include_in_schema=False) +async def admin_hosting_clients( + request: Request, + current_user: User = Depends(require_menu_access("hosting-clients", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render all client services overview page.""" + return templates.TemplateResponse( + "hosting/admin/clients.html", + get_admin_context(request, db, current_user), + ) diff --git a/app/modules/hosting/routes/pages/public.py b/app/modules/hosting/routes/pages/public.py new file mode 100644 index 00000000..fb7104ef --- /dev/null +++ b/app/modules/hosting/routes/pages/public.py @@ -0,0 +1,46 @@ +# app/modules/hosting/routes/pages/public.py +""" +Hosting Public Page Routes. + +Public-facing routes for POC site viewing: +- POC Viewer - Shows the Store's storefront with a HostWizard preview banner +""" + +from fastapi import APIRouter, Depends, Path, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.templates_config import templates + +router = APIRouter() + + +@router.get( + "/hosting/sites/{site_id}/preview", + response_class=HTMLResponse, + include_in_schema=False, +) +async def poc_site_viewer( + request: Request, + site_id: int = Path(..., description="Hosted Site ID"), + db: Session = Depends(get_db), +): + """Render POC site viewer with HostWizard preview banner.""" + from app.modules.hosting.models import HostedSite, HostedSiteStatus + + site = db.query(HostedSite).filter(HostedSite.id == site_id).first() + + # Only allow viewing for poc_ready or proposal_sent sites + if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT): + return HTMLResponse(content="

Site not available for preview

", status_code=404) + + context = { + "request": request, + "site": site, + "store_url": f"/stores/{site.store.subdomain}" if site.store else "#", + } + return templates.TemplateResponse( + "hosting/public/poc-viewer.html", + context, + ) diff --git a/app/modules/hosting/schemas/__init__.py b/app/modules/hosting/schemas/__init__.py new file mode 100644 index 00000000..31c15410 --- /dev/null +++ b/app/modules/hosting/schemas/__init__.py @@ -0,0 +1,32 @@ +# app/modules/hosting/schemas/__init__.py +from app.modules.hosting.schemas.client_service import ( + ClientServiceCreate, + ClientServiceDeleteResponse, + ClientServiceResponse, + ClientServiceUpdate, +) +from app.modules.hosting.schemas.hosted_site import ( + AcceptProposalRequest, + GoLiveRequest, + HostedSiteCreate, + HostedSiteDetailResponse, + HostedSiteListResponse, + HostedSiteResponse, + HostedSiteUpdate, + SendProposalRequest, +) + +__all__ = [ + "HostedSiteCreate", + "HostedSiteUpdate", + "HostedSiteResponse", + "HostedSiteDetailResponse", + "HostedSiteListResponse", + "SendProposalRequest", + "AcceptProposalRequest", + "GoLiveRequest", + "ClientServiceCreate", + "ClientServiceUpdate", + "ClientServiceResponse", + "ClientServiceDeleteResponse", +] diff --git a/app/modules/hosting/schemas/client_service.py b/app/modules/hosting/schemas/client_service.py new file mode 100644 index 00000000..c6239653 --- /dev/null +++ b/app/modules/hosting/schemas/client_service.py @@ -0,0 +1,80 @@ +# app/modules/hosting/schemas/client_service.py +"""Pydantic schemas for client service management.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ClientServiceCreate(BaseModel): + """Schema for creating a client service.""" + + service_type: str = Field(..., pattern="^(domain|email|ssl|hosting|website_maintenance)$") + name: str = Field(..., max_length=255) + description: str | None = None + billing_period: str | None = Field(None, pattern="^(monthly|annual|one_time)$") + price_cents: int | None = None + currency: str = Field("EUR", max_length=3) + addon_product_id: int | None = None + domain_name: str | None = Field(None, max_length=255) + registrar: str | None = Field(None, max_length=100) + mailbox_count: int | None = None + expires_at: datetime | None = None + period_start: datetime | None = None + period_end: datetime | None = None + auto_renew: bool = True + notes: str | None = None + + +class ClientServiceUpdate(BaseModel): + """Schema for updating a client service.""" + + name: str | None = Field(None, max_length=255) + description: str | None = None + status: str | None = Field(None, pattern="^(pending|active|suspended|expired|cancelled)$") + billing_period: str | None = Field(None, pattern="^(monthly|annual|one_time)$") + price_cents: int | None = None + currency: str | None = Field(None, max_length=3) + addon_product_id: int | None = None + domain_name: str | None = Field(None, max_length=255) + registrar: str | None = Field(None, max_length=100) + mailbox_count: int | None = None + expires_at: datetime | None = None + period_start: datetime | None = None + period_end: datetime | None = None + auto_renew: bool | None = None + notes: str | None = None + + +class ClientServiceResponse(BaseModel): + """Schema for client service response.""" + + id: int + hosted_site_id: int + service_type: str + name: str + description: str | None = None + status: str + billing_period: str | None = None + price_cents: int | None = None + currency: str = "EUR" + addon_product_id: int | None = None + domain_name: str | None = None + registrar: str | None = None + mailbox_count: int | None = None + expires_at: datetime | None = None + period_start: datetime | None = None + period_end: datetime | None = None + auto_renew: bool = True + notes: str | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ClientServiceDeleteResponse(BaseModel): + """Response for client service deletion.""" + + message: str diff --git a/app/modules/hosting/schemas/hosted_site.py b/app/modules/hosting/schemas/hosted_site.py new file mode 100644 index 00000000..e81aafc3 --- /dev/null +++ b/app/modules/hosting/schemas/hosted_site.py @@ -0,0 +1,101 @@ +# app/modules/hosting/schemas/hosted_site.py +"""Pydantic schemas for hosted site management.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class HostedSiteCreate(BaseModel): + """Schema for creating a hosted site.""" + + business_name: str = Field(..., max_length=255) + contact_name: str | None = Field(None, max_length=255) + contact_email: str | None = Field(None, max_length=255) + contact_phone: str | None = Field(None, max_length=50) + internal_notes: str | None = None + + +class HostedSiteUpdate(BaseModel): + """Schema for updating a hosted site.""" + + business_name: str | None = Field(None, max_length=255) + contact_name: str | None = Field(None, max_length=255) + contact_email: str | None = Field(None, max_length=255) + contact_phone: str | None = Field(None, max_length=50) + internal_notes: str | None = None + + +class SendProposalRequest(BaseModel): + """Schema for sending a proposal.""" + + notes: str | None = None + + +class AcceptProposalRequest(BaseModel): + """Schema for accepting a proposal.""" + + merchant_id: int | None = None + + +class GoLiveRequest(BaseModel): + """Schema for going live with a domain.""" + + domain: str = Field(..., max_length=255) + + +class HostedSiteResponse(BaseModel): + """Schema for hosted site response.""" + + id: int + store_id: int + prospect_id: int | None = None + status: str + business_name: str + contact_name: str | None = None + contact_email: str | None = None + contact_phone: str | None = None + proposal_sent_at: datetime | None = None + proposal_accepted_at: datetime | None = None + went_live_at: datetime | None = None + proposal_notes: str | None = None + live_domain: str | None = None + internal_notes: str | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class HostedSiteDetailResponse(HostedSiteResponse): + """Full hosted site detail with services.""" + + client_services: list["ClientServiceResponse"] = [] + + class Config: + from_attributes = True + + +class HostedSiteListResponse(BaseModel): + """Paginated hosted site list response.""" + + items: list[HostedSiteResponse] + total: int + page: int + per_page: int + pages: int + + +class HostedSiteDeleteResponse(BaseModel): + """Response for hosted site deletion.""" + + message: str + + +# Forward references +from app.modules.hosting.schemas.client_service import ( + ClientServiceResponse, # noqa: E402 +) + +HostedSiteDetailResponse.model_rebuild() diff --git a/app/modules/hosting/services/__init__.py b/app/modules/hosting/services/__init__.py new file mode 100644 index 00000000..b62f6cd9 --- /dev/null +++ b/app/modules/hosting/services/__init__.py @@ -0,0 +1 @@ +# app/modules/hosting/services/__init__.py diff --git a/app/modules/hosting/services/client_service_service.py b/app/modules/hosting/services/client_service_service.py new file mode 100644 index 00000000..05db1c80 --- /dev/null +++ b/app/modules/hosting/services/client_service_service.py @@ -0,0 +1,128 @@ +# app/modules/hosting/services/client_service_service.py +""" +Client service CRUD service. + +Manages operational tracking for hosted site services (domains, email, SSL, etc.). +""" + +import logging +from datetime import UTC, datetime, timedelta + +from sqlalchemy.orm import Session + +from app.modules.hosting.exceptions import ClientServiceNotFoundException +from app.modules.hosting.models import ( + ClientService, + ClientServiceStatus, + ServiceType, +) + +logger = logging.getLogger(__name__) + + +class ClientServiceService: + """Service for client service CRUD operations.""" + + def get_by_id(self, db: Session, service_id: int) -> ClientService: + service = db.query(ClientService).filter(ClientService.id == service_id).first() + if not service: + raise ClientServiceNotFoundException(str(service_id)) + return service + + def get_for_site(self, db: Session, hosted_site_id: int) -> list[ClientService]: + return ( + db.query(ClientService) + .filter(ClientService.hosted_site_id == hosted_site_id) + .order_by(ClientService.created_at.desc()) + .all() + ) + + def create(self, db: Session, hosted_site_id: int, data: dict) -> ClientService: + service = ClientService( + hosted_site_id=hosted_site_id, + service_type=ServiceType(data["service_type"]), + name=data["name"], + description=data.get("description"), + status=ClientServiceStatus.PENDING, + billing_period=data.get("billing_period"), + price_cents=data.get("price_cents"), + currency=data.get("currency", "EUR"), + addon_product_id=data.get("addon_product_id"), + domain_name=data.get("domain_name"), + registrar=data.get("registrar"), + mailbox_count=data.get("mailbox_count"), + expires_at=data.get("expires_at"), + period_start=data.get("period_start"), + period_end=data.get("period_end"), + auto_renew=data.get("auto_renew", True), + notes=data.get("notes"), + ) + db.add(service) + db.flush() + logger.info("Created client service: %s (site_id=%d)", service.name, hosted_site_id) + return service + + def update(self, db: Session, service_id: int, data: dict) -> ClientService: + service = self.get_by_id(db, service_id) + + for field in [ + "name", "description", "status", "billing_period", "price_cents", + "currency", "addon_product_id", "domain_name", "registrar", + "mailbox_count", "expires_at", "period_start", "period_end", + "auto_renew", "notes", + ]: + if field in data and data[field] is not None: + setattr(service, field, data[field]) + + db.flush() + return service + + def delete(self, db: Session, service_id: int) -> bool: + service = self.get_by_id(db, service_id) + db.delete(service) + db.flush() + logger.info("Deleted client service: %d", service_id) + return True + + def get_expiring_soon(self, db: Session, days: int = 30) -> list[ClientService]: + """Get services expiring within the given number of days.""" + cutoff = datetime.now(UTC) + timedelta(days=days) + return ( + db.query(ClientService) + .filter( + ClientService.expires_at.isnot(None), + ClientService.expires_at <= cutoff, + ClientService.status == ClientServiceStatus.ACTIVE, + ) + .order_by(ClientService.expires_at.asc()) + .all() + ) + + def get_all( + self, + db: Session, + *, + page: int = 1, + per_page: int = 20, + service_type: str | None = None, + status: str | None = None, + ) -> tuple[list[ClientService], int]: + """Get all services with pagination and filters.""" + query = db.query(ClientService) + + if service_type: + query = query.filter(ClientService.service_type == service_type) + if status: + query = query.filter(ClientService.status == status) + + total = query.count() + services = ( + query.order_by(ClientService.created_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + return services, total + + +client_service_service = ClientServiceService() diff --git a/app/modules/hosting/services/hosted_site_service.py b/app/modules/hosting/services/hosted_site_service.py new file mode 100644 index 00000000..f5fea879 --- /dev/null +++ b/app/modules/hosting/services/hosted_site_service.py @@ -0,0 +1,325 @@ +# app/modules/hosting/services/hosted_site_service.py +""" +Hosted site service — CRUD + lifecycle management. + +Manages the POC → live website pipeline: +draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled +""" + +import logging +import re +from datetime import UTC, datetime + +from sqlalchemy import or_ +from sqlalchemy.orm import Session, joinedload + +from app.modules.hosting.exceptions import ( + DuplicateSlugException, + HostedSiteNotFoundException, + InvalidStatusTransitionException, +) +from app.modules.hosting.models import HostedSite, HostedSiteStatus + +logger = logging.getLogger(__name__) + +# Valid status transitions +ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = { + HostedSiteStatus.DRAFT: [HostedSiteStatus.POC_READY, HostedSiteStatus.CANCELLED], + HostedSiteStatus.POC_READY: [HostedSiteStatus.PROPOSAL_SENT, HostedSiteStatus.CANCELLED], + HostedSiteStatus.PROPOSAL_SENT: [HostedSiteStatus.ACCEPTED, HostedSiteStatus.CANCELLED], + HostedSiteStatus.ACCEPTED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED], + HostedSiteStatus.LIVE: [HostedSiteStatus.SUSPENDED, HostedSiteStatus.CANCELLED], + HostedSiteStatus.SUSPENDED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED], + HostedSiteStatus.CANCELLED: [], +} + + +def _slugify(name: str) -> str: + """Generate a URL-safe slug from a business name.""" + slug = name.lower().strip() + slug = re.sub(r"[^a-z0-9\s-]", "", slug) + slug = re.sub(r"[\s-]+", "-", slug) + return slug.strip("-")[:50] + + +class HostedSiteService: + """Service for hosted site CRUD and lifecycle operations.""" + + def get_by_id(self, db: Session, site_id: int) -> HostedSite: + site = ( + db.query(HostedSite) + .options(joinedload(HostedSite.client_services)) + .filter(HostedSite.id == site_id) + .first() + ) + if not site: + raise HostedSiteNotFoundException(str(site_id)) + return site + + def get_all( + self, + db: Session, + *, + page: int = 1, + per_page: int = 20, + search: str | None = None, + status: str | None = None, + ) -> tuple[list[HostedSite], int]: + query = db.query(HostedSite).options(joinedload(HostedSite.client_services)) + + if search: + query = query.filter( + or_( + HostedSite.business_name.ilike(f"%{search}%"), + HostedSite.contact_email.ilike(f"%{search}%"), + HostedSite.live_domain.ilike(f"%{search}%"), + ) + ) + if status: + query = query.filter(HostedSite.status == status) + + total = query.count() + sites = ( + query.order_by(HostedSite.created_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + return sites, total + + def create(self, db: Session, data: dict) -> HostedSite: + """Create a hosted site with an auto-created Store on the hosting platform.""" + from app.modules.tenancy.models import Platform + from app.modules.tenancy.schemas.store import StoreCreate + from app.modules.tenancy.services.admin_service import admin_service + + business_name = data["business_name"] + slug = _slugify(business_name) + + # Find hosting platform + platform = db.query(Platform).filter(Platform.code == "hosting").first() + if not platform: + raise ValueError("Hosting platform not found. Run init_production first.") + + # Create a temporary merchant-less store requires a merchant_id. + # For POC sites we create a placeholder: the store is re-assigned on accept_proposal. + # Use the platform's own admin store or create under a system merchant. + # For now, create store via AdminService which handles defaults. + store_code = slug.upper().replace("-", "_")[:50] + subdomain = slug + + # Check for duplicate subdomain + from app.modules.tenancy.models import Store + + existing = db.query(Store).filter(Store.subdomain == subdomain).first() + if existing: + raise DuplicateSlugException(subdomain) + + # We need a system merchant for POC sites. + # Look for one or create if needed. + from app.modules.tenancy.models import Merchant + + system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first() + if not system_merchant: + system_merchant = Merchant( + name="HostWizard System", + contact_email="system@hostwizard.lu", + is_active=True, + is_verified=True, + ) + db.add(system_merchant) + db.flush() + + store_data = StoreCreate( + merchant_id=system_merchant.id, + store_code=store_code, + subdomain=subdomain, + name=business_name, + description=f"POC website for {business_name}", + platform_ids=[platform.id], + ) + store = admin_service.create_store(db, store_data) + + site = HostedSite( + store_id=store.id, + prospect_id=data.get("prospect_id"), + status=HostedSiteStatus.DRAFT, + business_name=business_name, + contact_name=data.get("contact_name"), + contact_email=data.get("contact_email"), + contact_phone=data.get("contact_phone"), + internal_notes=data.get("internal_notes"), + ) + db.add(site) + db.flush() + + logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id) + return site + + def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite: + """Create a hosted site pre-filled from prospect data.""" + from app.modules.prospecting.models import Prospect + + prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first() + if not prospect: + from app.modules.prospecting.exceptions import ProspectNotFoundException + + raise ProspectNotFoundException(str(prospect_id)) + + # Get primary contact info from prospect contacts + contacts = prospect.contacts or [] + primary_email = next((c.value for c in contacts if c.contact_type == "email"), None) + primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None) + contact_name = next((c.label for c in contacts if c.label), None) + + data = { + "business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}", + "contact_name": contact_name, + "contact_email": primary_email, + "contact_phone": primary_phone, + "prospect_id": prospect.id, + } + return self.create(db, data) + + def update(self, db: Session, site_id: int, data: dict) -> HostedSite: + site = self.get_by_id(db, site_id) + + for field in ["business_name", "contact_name", "contact_email", "contact_phone", "internal_notes"]: + if field in data and data[field] is not None: + setattr(site, field, data[field]) + + db.flush() + return site + + def delete(self, db: Session, site_id: int) -> bool: + site = self.get_by_id(db, site_id) + db.delete(site) + db.flush() + logger.info("Deleted hosted site: %d", site_id) + return True + + # ── Lifecycle transitions ────────────────────────────────────────────── + + def _transition(self, db: Session, site_id: int, target: HostedSiteStatus) -> HostedSite: + """Validate and apply a status transition.""" + site = self.get_by_id(db, site_id) + allowed = ALLOWED_TRANSITIONS.get(site.status, []) + if target not in allowed: + raise InvalidStatusTransitionException(site.status.value, target.value) + site.status = target + db.flush() + return site + + def mark_poc_ready(self, db: Session, site_id: int) -> HostedSite: + site = self._transition(db, site_id, HostedSiteStatus.POC_READY) + logger.info("Site %d marked POC ready", site_id) + return site + + def send_proposal(self, db: Session, site_id: int, notes: str | None = None) -> HostedSite: + site = self._transition(db, site_id, HostedSiteStatus.PROPOSAL_SENT) + site.proposal_sent_at = datetime.now(UTC) + if notes: + site.proposal_notes = notes + db.flush() + logger.info("Proposal sent for site %d", site_id) + return site + + def accept_proposal( + self, db: Session, site_id: int, merchant_id: int | None = None + ) -> HostedSite: + """Accept proposal: create or link merchant, create subscription, mark converted.""" + site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED) + site.proposal_accepted_at = datetime.now(UTC) + + from app.modules.tenancy.models import Merchant, Platform + + if merchant_id: + # Link to existing merchant + merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first() + if not merchant: + raise ValueError(f"Merchant {merchant_id} not found") + else: + # Create new merchant from contact info + from app.modules.tenancy.schemas.merchant import MerchantCreate + from app.modules.tenancy.services.merchant_service import merchant_service + + email = site.contact_email or f"contact-{site.id}@hostwizard.lu" + merchant_data = MerchantCreate( + name=site.business_name, + contact_email=email, + contact_phone=site.contact_phone, + owner_email=email, + ) + merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner( + db, merchant_data + ) + logger.info("Created merchant %s for site %d", merchant.name, site_id) + + # Re-assign store to the real merchant + site.store.merchant_id = merchant.id + db.flush() + + # Create MerchantSubscription on hosting platform + platform = db.query(Platform).filter(Platform.code == "hosting").first() + if platform: + from app.modules.billing.services.subscription_service import ( + subscription_service, + ) + + existing_sub = subscription_service.get_merchant_subscription( + db, merchant.id, platform.id + ) + if not existing_sub: + subscription_service.create_merchant_subscription( + db, + merchant_id=merchant.id, + platform_id=platform.id, + tier_code="essential", + trial_days=0, + ) + logger.info("Created subscription for merchant %d on hosting platform", merchant.id) + + # Mark prospect as converted + if site.prospect_id: + from app.modules.prospecting.models import Prospect, ProspectStatus + + prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first() + if prospect and prospect.status != ProspectStatus.CONVERTED: + prospect.status = ProspectStatus.CONVERTED + db.flush() + + db.flush() + logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id) + return site + + def go_live(self, db: Session, site_id: int, domain: str) -> HostedSite: + """Go live: add domain to store, update site.""" + site = self._transition(db, site_id, HostedSiteStatus.LIVE) + site.went_live_at = datetime.now(UTC) + site.live_domain = domain + + # Add domain to store via StoreDomainService + from app.modules.tenancy.schemas.store_domain import StoreDomainCreate + from app.modules.tenancy.services.store_domain_service import ( + store_domain_service, + ) + + domain_data = StoreDomainCreate(domain=domain, is_primary=True) + store_domain_service.add_domain(db, site.store_id, domain_data) + + db.flush() + logger.info("Site %d went live on %s", site_id, domain) + return site + + def suspend(self, db: Session, site_id: int) -> HostedSite: + site = self._transition(db, site_id, HostedSiteStatus.SUSPENDED) + logger.info("Site %d suspended", site_id) + return site + + def cancel(self, db: Session, site_id: int) -> HostedSite: + site = self._transition(db, site_id, HostedSiteStatus.CANCELLED) + logger.info("Site %d cancelled", site_id) + return site + + +hosted_site_service = HostedSiteService() diff --git a/app/modules/hosting/services/stats_service.py b/app/modules/hosting/services/stats_service.py new file mode 100644 index 00000000..6c374fdf --- /dev/null +++ b/app/modules/hosting/services/stats_service.py @@ -0,0 +1,101 @@ +# app/modules/hosting/services/stats_service.py +""" +Statistics service for the hosting dashboard. +""" + +import logging +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.modules.hosting.models import ( + ClientService, + ClientServiceStatus, + HostedSite, +) + +logger = logging.getLogger(__name__) + + +class StatsService: + """Service for dashboard statistics and reporting.""" + + def get_dashboard_stats(self, db: Session) -> dict: + """Get overview statistics for the hosting dashboard.""" + total = db.query(func.count(HostedSite.id)).scalar() or 0 + + # Sites by status + status_results = ( + db.query(HostedSite.status, func.count(HostedSite.id)) + .group_by(HostedSite.status) + .all() + ) + sites_by_status = { + status.value if hasattr(status, "value") else str(status): count + for status, count in status_results + } + + live_count = sites_by_status.get("live", 0) + poc_count = ( + sites_by_status.get("draft", 0) + + sites_by_status.get("poc_ready", 0) + + sites_by_status.get("proposal_sent", 0) + ) + + # Active services + active_services = ( + db.query(func.count(ClientService.id)) + .filter(ClientService.status == ClientServiceStatus.ACTIVE) + .scalar() + or 0 + ) + + # Monthly revenue (sum of price_cents for active services with monthly billing) + monthly_revenue = ( + db.query(func.sum(ClientService.price_cents)) + .filter( + ClientService.status == ClientServiceStatus.ACTIVE, + ) + .scalar() + or 0 + ) + + # Upcoming renewals (next 30 days) + cutoff = datetime.now(UTC) + timedelta(days=30) + upcoming_renewals = ( + db.query(func.count(ClientService.id)) + .filter( + ClientService.expires_at.isnot(None), + ClientService.expires_at <= cutoff, + ClientService.status == ClientServiceStatus.ACTIVE, + ) + .scalar() + or 0 + ) + + # Services by type + type_results = ( + db.query(ClientService.service_type, func.count(ClientService.id)) + .filter(ClientService.status == ClientServiceStatus.ACTIVE) + .group_by(ClientService.service_type) + .all() + ) + services_by_type = { + stype.value if hasattr(stype, "value") else str(stype): count + for stype, count in type_results + } + + return { + "total_sites": total, + "live_sites": live_count, + "poc_sites": poc_count, + "sites_by_status": sites_by_status, + "active_services": active_services, + "monthly_revenue_cents": monthly_revenue, + "upcoming_renewals": upcoming_renewals, + "services_by_type": services_by_type, + } + + +stats_service = StatsService() diff --git a/app/modules/hosting/static/admin/js/.gitkeep b/app/modules/hosting/static/admin/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/hosting/tasks/__init__.py b/app/modules/hosting/tasks/__init__.py new file mode 100644 index 00000000..a3d5aaab --- /dev/null +++ b/app/modules/hosting/tasks/__init__.py @@ -0,0 +1 @@ +# app/modules/hosting/tasks/__init__.py diff --git a/app/modules/hosting/templates/hosting/admin/clients.html b/app/modules/hosting/templates/hosting/admin/clients.html new file mode 100644 index 00000000..beb8232e --- /dev/null +++ b/app/modules/hosting/templates/hosting/admin/clients.html @@ -0,0 +1,165 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/pagination.html' import pagination_controls %} + +{% block title %}Client Services{% endblock %} + +{% block alpine_data %}hostingClientsList(){% endblock %} + +{% block content %} +{{ page_header('Client Services') }} + + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +{{ loading_state('Loading services...') }} +{{ error_state('Error loading services') }} + + +
+
+ + + + + + + + + + + + + + +
ServiceTypeStatusPriceExpiresSite
+
+
+ No services found +
+
+ +{{ pagination_controls() }} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/hosting/templates/hosting/admin/dashboard.html b/app/modules/hosting/templates/hosting/admin/dashboard.html new file mode 100644 index 00000000..596da45c --- /dev/null +++ b/app/modules/hosting/templates/hosting/admin/dashboard.html @@ -0,0 +1,148 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Hosting Dashboard{% endblock %} + +{% block alpine_data %}hostingDashboard(){% endblock %} + +{% block content %} +{{ page_header('Hosting Dashboard') }} +{{ loading_state('Loading dashboard...') }} +{{ error_state('Error loading dashboard') }} + +
+ +
+
+
+ +
+
+

Total Sites

+

+
+
+
+
+ +
+
+

Live Sites

+

+
+
+
+
+ +
+
+

POC Sites

+

+
+
+
+
+ +
+
+

Upcoming Renewals

+

+
+
+
+ + +
+ + + New Site + + + + All Sites + + + + All Services + +
+ + +
+
+

Sites by Status

+
+ +
+
+
+

Active Services

+
+
+ Total Active + +
+ +
+
+
+ Monthly Revenue + +
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/hosting/templates/hosting/admin/site-detail.html b/app/modules/hosting/templates/hosting/admin/site-detail.html new file mode 100644 index 00000000..c2fffbda --- /dev/null +++ b/app/modules/hosting/templates/hosting/admin/site-detail.html @@ -0,0 +1,405 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/inputs.html' import number_stepper %} + +{% block title %}Site Detail{% endblock %} + +{% block alpine_data %}hostingSiteDetail({{ site_id }}){% endblock %} + +{% block content %} +{{ loading_state('Loading site...') }} +{{ error_state('Error loading site') }} + +
+ +
+
+ + + +
+

+

+
+ +
+
+ + +
+ + + + + + + + + + Preview POC + +
+ + +
+ +
+ + +
+
+

Contact Info

+
+
+ Name + +
+
+ Email + +
+
+ Phone + +
+
+
+
+

Timeline

+
+
+ Created + +
+
+ Proposal Sent + +
+
+ Accepted + +
+
+ Went Live + +
+
+
+
+

Notes

+
+

Proposal Notes

+

+
+
+

Internal Notes

+

+
+
+
+ + +
+
+ +
+ +

No services yet

+
+ + +
+

Store Info

+
+
+ Store ID + +
+
+ +
+
+ + {# noqa: FE-004 #} +
+
+
+

Send Proposal

+ +
+
+ + +
+ +
+
+ + {# noqa: FE-004 #} +
+
+
+

Accept Proposal

+ +
+
+ + +
+ +
+
+ + {# noqa: FE-004 #} +
+
+
+

Go Live

+ +
+
+ + +
+ +
+
+ + {# noqa: FE-004 #} +
+
+
+

Add Service

+ +
+
+
+ + +
+
+ + +
+
+
+ + {{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }} +
+
+ + +
+
+
+ +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/hosting/templates/hosting/admin/site-new.html b/app/modules/hosting/templates/hosting/admin/site-new.html new file mode 100644 index 00000000..925bacc1 --- /dev/null +++ b/app/modules/hosting/templates/hosting/admin/site-new.html @@ -0,0 +1,115 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} + +{% block title %}New Hosted Site{% endblock %} + +{% block alpine_data %}hostingSiteNew(){% endblock %} + +{% block content %} +{{ page_header('New Hosted Site') }} + +
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+ +
+ + Cancel + + +
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/hosting/templates/hosting/admin/sites.html b/app/modules/hosting/templates/hosting/admin/sites.html new file mode 100644 index 00000000..7f371d9a --- /dev/null +++ b/app/modules/hosting/templates/hosting/admin/sites.html @@ -0,0 +1,152 @@ +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_header, table_empty %} +{% from 'shared/macros/pagination.html' import pagination_controls %} + +{% block title %}Hosted Sites{% endblock %} + +{% block alpine_data %}hostingSitesList(){% endblock %} + +{% block content %} +{{ page_header('Hosted Sites', action_label='New Site', action_href='/admin/hosting/sites/new', action_icon='plus') }} + + +
+
+
+ + +
+
+ + +
+ +
+
+ +{{ loading_state('Loading sites...') }} +{{ error_state('Error loading sites') }} + + +
+
+ + + + + + + + + + + + + + +
BusinessStatusContactDomainCreatedActions
+
+ {{ table_empty('No hosted sites found') }} +
+ +{{ pagination_controls() }} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/hosting/templates/hosting/public/poc-viewer.html b/app/modules/hosting/templates/hosting/public/poc-viewer.html new file mode 100644 index 00000000..fcb0e608 --- /dev/null +++ b/app/modules/hosting/templates/hosting/public/poc-viewer.html @@ -0,0 +1,67 @@ + + + + + + {{ site.business_name }} - Preview by HostWizard + + + +
+
+ + Preview for {{ site.business_name }} +
+
+ hostwizard.lu +
+
+
+ +
+ + diff --git a/app/modules/prospecting/migrations/versions/prospecting_001_initial.py b/app/modules/prospecting/migrations/versions/prospecting_001_initial.py index f0775aeb..1082d6fe 100644 --- a/app/modules/prospecting/migrations/versions/prospecting_001_initial.py +++ b/app/modules/prospecting/migrations/versions/prospecting_001_initial.py @@ -1,7 +1,7 @@ """prospecting: initial tables for lead discovery and campaign management Revision ID: prospecting_001 -Revises: None +Revises: cms_002 Create Date: 2026-02-27 """ import sqlalchemy as sa @@ -9,8 +9,8 @@ import sqlalchemy as sa from alembic import op revision = "prospecting_001" -down_revision = None -branch_labels = ("prospecting",) +down_revision = "cms_002" +branch_labels = None depends_on = None diff --git a/docs/deployment/hetzner-server-setup.md b/docs/deployment/hetzner-server-setup.md index 725e7631..889cde23 100644 --- a/docs/deployment/hetzner-server-setup.md +++ b/docs/deployment/hetzner-server-setup.md @@ -611,7 +611,16 @@ It should match the value in the Hetzner Cloud Console (Networking tab). Then cr | AAAA | `git` | `2a01:4f8:1c1a:b39c::1` | 300 | | AAAA | `flower` | `2a01:4f8:1c1a:b39c::1` | 300 | -Repeat for `omsflow.lu` and `rewardflow.lu`. +Repeat for `omsflow.lu`, `rewardflow.lu`, and `hostwizard.lu`. + +**hostwizard.lu DNS Records:** + +| Type | Name | Value | TTL | +|---|---|---|---| +| A | `@` | `91.99.65.229` | 300 | +| A | `www` | `91.99.65.229` | 300 | +| AAAA | `@` | `2a01:4f8:1c1a:b39c::1` | 300 | +| AAAA | `www` | `2a01:4f8:1c1a:b39c::1` | 300 | !!! tip "DNS propagation" Set TTL to 300 (5 minutes) initially. DNS changes can take up to 24 hours to propagate globally, but usually complete within 30 minutes. Verify with: `dig api.wizard.lu +short` @@ -661,6 +670,15 @@ www.rewardflow.lu { redir https://rewardflow.lu{uri} permanent } +# ─── Platform 4: HostWizard (hostwizard.lu) ────────────────── +hostwizard.lu { + reverse_proxy localhost:8001 +} + +www.hostwizard.lu { + redir https://hostwizard.lu{uri} permanent +} + # ─── Services ─────────────────────────────────────────────── api.wizard.lu { reverse_proxy localhost:8001 @@ -676,7 +694,7 @@ flower.wizard.lu { ``` !!! info "How multi-platform routing works" - All platform domains (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`) point to the **same FastAPI backend** on port 8001. The `PlatformContextMiddleware` reads the `Host` header to detect which platform the request is for. Caddy preserves the Host header by default, so no extra configuration is needed. + All platform domains (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`, `hostwizard.lu`) point to the **same FastAPI backend** on port 8001. The `PlatformContextMiddleware` reads the `Host` header to detect which platform the request is for. Caddy preserves the Host header by default, so no extra configuration is needed. The `domain` column in the `platforms` database table must match: @@ -685,6 +703,7 @@ flower.wizard.lu { | Main | `main` | `wizard.lu` | | OMS | `oms` | `omsflow.lu` | | Loyalty+ | `loyalty` | `rewardflow.lu` | + | HostWizard | `hosting` | `hostwizard.lu` | Start Caddy: @@ -1909,6 +1928,7 @@ Cloudflare Origin Certificates (free, 15-year validity) avoid ACME challenge iss - `wizard.lu`: `wizard.lu, api.wizard.lu, www.wizard.lu, flower.wizard.lu, grafana.wizard.lu` - `omsflow.lu`: `omsflow.lu, www.omsflow.lu` - `rewardflow.lu`: `rewardflow.lu, www.rewardflow.lu` + - `hostwizard.lu`: `hostwizard.lu, www.hostwizard.lu` 3. Download the certificate and private key (private key is shown only once) !!! warning "Do NOT use wildcard origin certs for wizard.lu" @@ -1917,12 +1937,12 @@ Cloudflare Origin Certificates (free, 15-year validity) avoid ACME challenge iss Install on the server: ```bash -sudo mkdir -p /etc/caddy/certs/{wizard.lu,omsflow.lu,rewardflow.lu} +sudo mkdir -p /etc/caddy/certs/{wizard.lu,omsflow.lu,rewardflow.lu,hostwizard.lu} # For each domain, create cert.pem and key.pem: sudo nano /etc/caddy/certs/wizard.lu/cert.pem # paste certificate sudo nano /etc/caddy/certs/wizard.lu/key.pem # paste private key -# Repeat for omsflow.lu and rewardflow.lu +# Repeat for omsflow.lu, rewardflow.lu, and hostwizard.lu sudo chown -R caddy:caddy /etc/caddy/certs/ sudo chmod 600 /etc/caddy/certs/*/key.pem @@ -1976,6 +1996,17 @@ www.rewardflow.lu { redir https://rewardflow.lu{uri} permanent } +# ─── Platform 4: HostWizard (hostwizard.lu) ────────────────── +hostwizard.lu { + tls /etc/caddy/certs/hostwizard.lu/cert.pem /etc/caddy/certs/hostwizard.lu/key.pem + reverse_proxy localhost:8001 +} + +www.hostwizard.lu { + tls /etc/caddy/certs/hostwizard.lu/cert.pem /etc/caddy/certs/hostwizard.lu/key.pem + redir https://hostwizard.lu{uri} permanent +} + # ─── Services (wizard.lu origin cert) ─────────────────────── api.wizard.lu { tls /etc/caddy/certs/wizard.lu/cert.pem /etc/caddy/certs/wizard.lu/key.pem @@ -2010,7 +2041,7 @@ sudo systemctl status caddy ### 21.6 Cloudflare Settings (per domain) -Configure these in the Cloudflare dashboard for each domain (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`): +Configure these in the Cloudflare dashboard for each domain (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`, `hostwizard.lu`): | Setting | Location | Value | |---|---|---| diff --git a/docs/features/user-journeys/hosting.md b/docs/features/user-journeys/hosting.md new file mode 100644 index 00000000..ecf68cd1 --- /dev/null +++ b/docs/features/user-journeys/hosting.md @@ -0,0 +1,502 @@ +# Hosting Module - User Journeys + +## Personas + +| # | Persona | Role / Auth | Description | +|---|---------|-------------|-------------| +| 1 | **Platform Admin** | `admin` role | Manages the POC → live website pipeline, tracks client services, monitors renewals | +| 2 | **Prospect** | No auth (receives proposal link) | Views their POC website preview via a shared link | + +!!! note "Admin-only module" + The hosting module is primarily an admin-only module. The only non-admin page is the + **POC Viewer** — a public preview page that shows the prospect's POC website with a + HostWizard banner. Prospects do not have accounts until their proposal is accepted, at + which point a Merchant account is created for them. + +--- + +## Lifecycle Overview + +The hosting module manages the complete POC → live website pipeline: + +```mermaid +flowchart TD + A[Prospect identified] --> B[Create Hosted Site] + B --> C[Status: DRAFT] + C --> D[Build POC website via CMS] + D --> E[Mark POC Ready] + E --> F[Status: POC_READY] + F --> G[Send Proposal to prospect] + G --> H[Status: PROPOSAL_SENT] + H --> I{Prospect accepts?} + I -->|Yes| J[Accept Proposal] + J --> K[Status: ACCEPTED] + K --> L[Merchant account created] + L --> M[Go Live with domain] + M --> N[Status: LIVE] + I -->|No| O[Cancel] + O --> P[Status: CANCELLED] + N --> Q{Issues?} + Q -->|Payment issues| R[Suspend] + R --> S[Status: SUSPENDED] + S --> T[Reactivate → LIVE] + Q -->|Client leaves| O +``` + +### Status Transitions + +| From | Allowed Targets | +|------|----------------| +| `draft` | `poc_ready`, `cancelled` | +| `poc_ready` | `proposal_sent`, `cancelled` | +| `proposal_sent` | `accepted`, `cancelled` | +| `accepted` | `live`, `cancelled` | +| `live` | `suspended`, `cancelled` | +| `suspended` | `live`, `cancelled` | +| `cancelled` | _(terminal)_ | + +--- + +## Dev URLs (localhost:9999) + +The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...` + +### 1. Admin Pages + +Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com` + +| Page | Dev URL | +|------|---------| +| Dashboard | `http://localhost:9999/platforms/hosting/admin/hosting` | +| Sites List | `http://localhost:9999/platforms/hosting/admin/hosting/sites` | +| New Site | `http://localhost:9999/platforms/hosting/admin/hosting/sites/new` | +| Site Detail | `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` | +| Client Services | `http://localhost:9999/platforms/hosting/admin/hosting/clients` | + +### 2. Public Pages + +| Page | Dev URL | +|------|---------| +| POC Viewer | `http://localhost:9999/platforms/hosting/hosting/sites/{site_id}/preview` | + +### 3. Admin API Endpoints + +**Sites** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | list sites | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` | +| GET | site detail | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` | +| POST | create site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` | +| POST | create from prospect | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}` | +| PUT | update site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` | +| DELETE | delete site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` | + +**Lifecycle** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| POST | mark POC ready | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready` | +| POST | send proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal` | +| POST | accept proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept` | +| POST | go live | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live` | +| POST | suspend | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend` | +| POST | cancel | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel` | + +**Client Services** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | list services | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` | +| POST | create service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` | +| PUT | update service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` | +| DELETE | delete service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` | + +**Stats** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard` | + +--- + +## Production URLs (hostwizard.lu) + +In production, the platform uses **domain-based routing**. + +### Admin Pages & API + +| Page / Endpoint | Production URL | +|-----------------|----------------| +| Dashboard | `https://hostwizard.lu/admin/hosting` | +| Sites | `https://hostwizard.lu/admin/hosting/sites` | +| New Site | `https://hostwizard.lu/admin/hosting/sites/new` | +| Site Detail | `https://hostwizard.lu/admin/hosting/sites/{id}` | +| Client Services | `https://hostwizard.lu/admin/hosting/clients` | +| API - Sites | `GET https://hostwizard.lu/api/admin/hosting/sites` | +| API - Stats | `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard` | + +### Public Pages + +| Page | Production URL | +|------|----------------| +| POC Viewer | `https://hostwizard.lu/hosting/sites/{site_id}/preview` | + +--- + +## Data Model + +### Hosted Site + +``` +HostedSite +├── id (PK) +├── store_id (FK → stores.id, unique) # The CMS-powered website +├── prospect_id (FK → prospects.id, nullable) # Origin prospect +├── status: draft | poc_ready | proposal_sent | accepted | live | suspended | cancelled +├── business_name (str) +├── contact_name, contact_email, contact_phone +├── proposal_sent_at, proposal_accepted_at, went_live_at (datetime) +├── proposal_notes (text) +├── live_domain (str, unique) +├── internal_notes (text) +├── created_at, updated_at +└── Relationships: store, prospect, client_services +``` + +### Client Service + +``` +ClientService +├── id (PK) +├── hosted_site_id (FK → hosted_sites.id, CASCADE) +├── service_type: domain | email | ssl | hosting | website_maintenance +├── name (str) # e.g., "acme.lu domain", "5 mailboxes" +├── status: pending | active | suspended | expired | cancelled +├── billing_period: monthly | annual | one_time +├── price_cents (int), currency (str, default EUR) +├── addon_product_id (FK, nullable) # Link to billing product +├── domain_name, registrar # Domain-specific +├── mailbox_count # Email-specific +├── expires_at, period_start, period_end, auto_renew +├── notes (text) +└── created_at, updated_at +``` + +--- + +## User Journeys + +### Journey 1: Create Hosted Site from Prospect + +**Persona:** Platform Admin +**Goal:** Convert a qualified prospect into a hosted site with a POC website + +**Prerequisite:** A prospect exists in the prospecting module (see [Prospecting Journeys](prospecting.md)) + +```mermaid +flowchart TD + A[View prospect in prospecting module] --> B[Click 'Create Hosted Site from Prospect'] + B --> C[HostedSite created with status DRAFT] + C --> D[Store auto-created on hosting platform] + D --> E[Contact info pre-filled from prospect] + E --> F[Navigate to site detail] + F --> G[Build POC website via CMS editor] +``` + +**Steps:** + +1. Create hosted site from prospect: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/from-prospect/{prospect_id}` +2. This automatically: + - Creates a Store on the hosting platform + - Creates a HostedSite record linked to the Store and Prospect + - Pre-fills business_name, contact_name, contact_email, contact_phone from prospect data +3. View the new site: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` + - Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}` +4. Click the Store link to open the CMS editor and build the POC website + +--- + +### Journey 2: Create Hosted Site Manually + +**Persona:** Platform Admin +**Goal:** Create a hosted site without an existing prospect (e.g., direct referral) + +```mermaid +flowchart TD + A[Navigate to New Site page] --> B[Fill in business details] + B --> C[Submit form] + C --> D[HostedSite + Store created] + D --> E[Navigate to site detail] + E --> F[Build POC website] +``` + +**Steps:** + +1. Navigate to New Site form: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/new` + - Prod: `https://hostwizard.lu/admin/hosting/sites/new` +2. Create the site: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites` + - Body: `{ "business_name": "Boulangerie du Parc", "contact_name": "Jean Müller", "contact_email": "jean@boulangerie-parc.lu", "contact_phone": "+352 26 123 456" }` +3. A Store is auto-created with subdomain `boulangerie-du-parc` on the hosting platform + +--- + +### Journey 3: POC → Proposal Flow + +**Persona:** Platform Admin +**Goal:** Build a POC website, mark it ready, and send a proposal to the prospect + +```mermaid +flowchart TD + A[Site is DRAFT] --> B[Build POC website via CMS] + B --> C[Mark POC Ready] + C --> D[Site is POC_READY] + D --> E[Preview the POC site] + E --> F[Send Proposal with notes] + F --> G[Site is PROPOSAL_SENT] + G --> H[Share preview link with prospect] +``` + +**Steps:** + +1. Build the POC website using the Store's CMS editor (linked from site detail page) +2. When the POC is ready, mark it: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/mark-poc-ready` +3. Preview the POC site (public link, no auth needed): + - Dev: `http://localhost:9999/platforms/hosting/hosting/sites/{id}/preview` + - Prod: `https://hostwizard.lu/hosting/sites/{id}/preview` +4. Send proposal to the prospect: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/send-proposal` + - Body: `{ "notes": "Custom website with 5 pages, domain registration included" }` +5. Share the preview link with the prospect via email + +!!! info "POC Viewer" + The POC Viewer page renders the Store's storefront in an iframe with a teal + HostWizard banner at the top. It only works for sites with status `poc_ready` + or `proposal_sent`. Once the site goes live, the preview is disabled. + +--- + +### Journey 4: Accept Proposal & Create Merchant + +**Persona:** Platform Admin +**Goal:** When a prospect accepts, create their merchant account and subscription + +```mermaid +flowchart TD + A[Prospect accepts proposal] --> B{Existing merchant?} + B -->|Yes| C[Link to existing merchant] + B -->|No| D[Auto-create merchant + owner account] + C --> E[Accept Proposal] + D --> E + E --> F[Site is ACCEPTED] + F --> G[Store reassigned to merchant] + G --> H[Subscription created on hosting platform] + H --> I[Prospect marked as CONVERTED] +``` + +**Steps:** + +1. Accept the proposal (auto-creates merchant if no merchant_id provided): + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/accept` + - Body: `{}` (auto-create merchant) or `{ "merchant_id": 5 }` (link to existing) +2. This automatically: + - Creates a new Merchant from contact info (name, email, phone) + - Creates a store owner account with a temporary password + - Reassigns the Store from the system merchant to the new merchant + - Creates a MerchantSubscription on the hosting platform (essential tier) + - Marks the linked prospect as CONVERTED (if prospect_id is set) +3. View the updated site detail: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{id}` + - Prod: `https://hostwizard.lu/admin/hosting/sites/{id}` + +!!! warning "Merchant account credentials" + When accepting without an existing `merchant_id`, a new merchant owner account is + created with a temporary password. The admin should communicate these credentials + to the client so they can log in and self-edit their website via the CMS. + +--- + +### Journey 5: Go Live with Custom Domain + +**Persona:** Platform Admin +**Goal:** Assign a production domain to the website and make it live + +```mermaid +flowchart TD + A[Site is ACCEPTED] --> B[Configure DNS for client domain] + B --> C[Go Live with domain] + C --> D[Site is LIVE] + D --> E[StoreDomain created] + E --> F[Website accessible at client domain] +``` + +**Steps:** + +1. Ensure DNS is configured for the client's domain (A/AAAA records pointing to the server) +2. Go live: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/go-live` + - Body: `{ "domain": "boulangerie-parc.lu" }` +3. This automatically: + - Sets `went_live_at` timestamp + - Creates a StoreDomain record (primary) for the domain + - Sets `live_domain` on the hosted site +4. The website is now accessible at `https://boulangerie-parc.lu` + +--- + +### Journey 6: Add Client Services + +**Persona:** Platform Admin +**Goal:** Track operational services (domains, email, SSL, hosting) for a client + +```mermaid +flowchart TD + A[Open site detail] --> B[Go to Services tab] + B --> C[Add domain service] + C --> D[Add email service] + D --> E[Add SSL service] + E --> F[Add hosting service] + F --> G[Services tracked with expiry dates] +``` + +**Steps:** + +1. Navigate to site detail, Services tab: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` + - Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}` +2. Add a domain service: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services` + - Body: `{ "service_type": "domain", "name": "boulangerie-parc.lu domain", "domain_name": "boulangerie-parc.lu", "registrar": "Namecheap", "billing_period": "annual", "price_cents": 1500, "expires_at": "2027-03-01T00:00:00", "auto_renew": true }` +3. Add an email service: + - Body: `{ "service_type": "email", "name": "5 mailboxes", "mailbox_count": 5, "billing_period": "monthly", "price_cents": 999 }` +4. Add an SSL service: + - Body: `{ "service_type": "ssl", "name": "SSL certificate", "billing_period": "annual", "price_cents": 0, "expires_at": "2027-03-01T00:00:00" }` +5. View all services for a site: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` + - API Prod: `GET https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services` + +--- + +### Journey 7: Dashboard & Renewal Monitoring + +**Persona:** Platform Admin +**Goal:** Monitor business KPIs and upcoming service renewals + +```mermaid +flowchart TD + A[Navigate to Dashboard] --> B[View KPIs] + B --> C[Total sites, live sites, POC sites] + C --> D[Monthly revenue] + D --> E[Active services count] + E --> F[Upcoming renewals in 30 days] + F --> G[Navigate to Client Services] + G --> H[Filter by expiring soon] + H --> I[Renew or update services] +``` + +**Steps:** + +1. Navigate to Dashboard: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting` + - Prod: `https://hostwizard.lu/admin/hosting` +2. View dashboard stats: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard` + - API Prod: `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard` + - Returns: `total_sites`, `live_sites`, `poc_sites`, `sites_by_status`, `active_services`, `monthly_revenue_cents`, `upcoming_renewals`, `services_by_type` +3. Navigate to Client Services for detailed view: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/clients` + - Prod: `https://hostwizard.lu/admin/hosting/clients` +4. Filter by type (domain, email, ssl, hosting) or status +5. Toggle "Expiring Soon" to see services expiring within 30 days + +--- + +### Journey 8: Suspend & Reactivate + +**Persona:** Platform Admin +**Goal:** Handle suspension (e.g., unpaid invoices) and reactivation + +**Steps:** + +1. Suspend a site: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/suspend` +2. Site status changes to `suspended` +3. Once payment is resolved, reactivate by transitioning back to live: + - The `suspended → live` transition is allowed +4. To permanently close a site: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/cancel` +5. `cancelled` is a terminal state — no further transitions allowed + +--- + +### Journey 9: Complete Pipeline (Prospect → Live Site) + +**Persona:** Platform Admin +**Goal:** Walk the complete pipeline from prospect to live website + +This journey combines the prospecting and hosting modules end-to-end: + +```mermaid +flowchart TD + A[Import domain / capture lead] --> B[Enrich & score prospect] + B --> C[Create hosted site from prospect] + C --> D[Build POC website via CMS] + D --> E[Mark POC ready] + E --> F[Send proposal + share preview link] + F --> G{Prospect accepts?} + G -->|Yes| H[Accept → Merchant created] + H --> I[Add client services] + I --> J[Go live with domain] + J --> K[Website live at client domain] + K --> L[Monitor renewals & services] + G -->|No| M[Cancel or follow up later] +``` + +**Steps:** + +1. **Prospecting phase** (see [Prospecting Journeys](prospecting.md)): + - Import domain or capture lead offline + - Run enrichment pipeline + - Score and qualify the prospect +2. **Create hosted site**: `POST /api/admin/hosting/sites/from-prospect/{prospect_id}` +3. **Build POC**: Edit the auto-created Store via CMS +4. **Mark POC ready**: `POST /api/admin/hosting/sites/{id}/mark-poc-ready` +5. **Send proposal**: `POST /api/admin/hosting/sites/{id}/send-proposal` +6. **Share preview**: Send `https://hostwizard.lu/hosting/sites/{id}/preview` to prospect +7. **Accept proposal**: `POST /api/admin/hosting/sites/{id}/accept` +8. **Add services**: `POST /api/admin/hosting/sites/{id}/services` (domain, email, SSL, hosting) +9. **Go live**: `POST /api/admin/hosting/sites/{id}/go-live` with domain +10. **Monitor**: Dashboard at `https://hostwizard.lu/admin/hosting` + +--- + +## Recommended Test Order + +1. **Journey 2** - Create a site manually first (simplest path, no prospect dependency) +2. **Journey 3** - Walk the POC → proposal flow +3. **Journey 4** - Accept proposal and verify merchant creation +4. **Journey 5** - Go live with a test domain +5. **Journey 6** - Add client services +6. **Journey 7** - Check dashboard stats +7. **Journey 1** - Test the prospect → hosted site conversion (requires prospecting data) +8. **Journey 8** - Test suspend/reactivate/cancel +9. **Journey 9** - Walk the complete end-to-end pipeline + +!!! tip "Test Journey 2 before Journey 1" + Journey 2 (manual creation) doesn't require any prospecting data and is the fastest + way to verify the hosting module works. Journey 1 (from prospect) requires running + the prospecting module first. diff --git a/docs/features/user-journeys/prospecting.md b/docs/features/user-journeys/prospecting.md new file mode 100644 index 00000000..abf4c470 --- /dev/null +++ b/docs/features/user-journeys/prospecting.md @@ -0,0 +1,435 @@ +# Prospecting Module - User Journeys + +## Personas + +| # | Persona | Role / Auth | Description | +|---|---------|-------------|-------------| +| 1 | **Platform Admin** | `admin` role | Manages prospects, runs enrichment scans, sends campaigns, exports leads | + +!!! note "Admin-only module" + The prospecting module is exclusively for platform admins. There are no store-level + or customer-facing pages. All access requires admin authentication. + +--- + +## Platforms Using Prospecting + +The prospecting module is enabled on multiple platforms: + +| Platform | Domain | Path Prefix (dev) | +|----------|--------|--------------------| +| HostWizard | hostwizard.lu | `/platforms/hosting/` | + +--- + +## Dev URLs (localhost:9999) + +The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...` + +### 1. Admin Pages + +Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com` + +| Page | Dev URL | +|------|---------| +| Dashboard | `http://localhost:9999/platforms/hosting/admin/prospecting` | +| Prospects List | `http://localhost:9999/platforms/hosting/admin/prospecting/prospects` | +| Prospect Detail | `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{prospect_id}` | +| Leads List | `http://localhost:9999/platforms/hosting/admin/prospecting/leads` | +| Quick Capture | `http://localhost:9999/platforms/hosting/admin/prospecting/capture` | +| Scan Jobs | `http://localhost:9999/platforms/hosting/admin/prospecting/scan-jobs` | +| Campaigns | `http://localhost:9999/platforms/hosting/admin/prospecting/campaigns` | + +### 2. Admin API Endpoints + +**Prospects** (prefix: `/platforms/hosting/api/admin/prospecting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | prospects | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` | +| GET | prospect detail | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` | +| POST | create prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` | +| PUT | update prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` | +| DELETE | delete prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` | +| POST | import CSV | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/import` | + +**Leads** (prefix: `/platforms/hosting/api/admin/prospecting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | leads (filtered) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads` | +| GET | top priority | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/top-priority` | +| GET | quick wins | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/quick-wins` | +| GET | export CSV | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv` | + +**Enrichment** (prefix: `/platforms/hosting/api/admin/prospecting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| POST | HTTP check (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/{id}` | +| POST | HTTP check (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/batch` | +| POST | tech scan (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/{id}` | +| POST | tech scan (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/batch` | +| POST | performance (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/{id}` | +| POST | performance (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/batch` | +| POST | contacts (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/contacts/{id}` | +| POST | full enrichment | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{id}` | +| POST | score compute | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/score-compute/batch` | + +**Interactions** (prefix: `/platforms/hosting/api/admin/prospecting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | prospect interactions | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` | +| POST | log interaction | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` | +| GET | upcoming follow-ups | `http://localhost:9999/platforms/hosting/api/admin/prospecting/interactions/upcoming` | + +**Campaigns** (prefix: `/platforms/hosting/api/admin/prospecting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | list templates | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` | +| POST | create template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` | +| PUT | update template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates/{id}` | +| DELETE | delete template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates/{id}` | +| POST | preview campaign | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/preview` | +| POST | send campaign | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/send` | +| GET | list sends | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/sends` | + +**Stats** (prefix: `/platforms/hosting/api/admin/prospecting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/prospecting/stats` | +| GET | scan jobs | `http://localhost:9999/platforms/hosting/api/admin/prospecting/stats/jobs` | + +--- + +## Production URLs (hostwizard.lu) + +In production, the platform uses **domain-based routing**. + +### Admin Pages & API + +| Page / Endpoint | Production URL | +|-----------------|----------------| +| Dashboard | `https://hostwizard.lu/admin/prospecting` | +| Prospects | `https://hostwizard.lu/admin/prospecting/prospects` | +| Prospect Detail | `https://hostwizard.lu/admin/prospecting/prospects/{id}` | +| Leads | `https://hostwizard.lu/admin/prospecting/leads` | +| Quick Capture | `https://hostwizard.lu/admin/prospecting/capture` | +| Scan Jobs | `https://hostwizard.lu/admin/prospecting/scan-jobs` | +| Campaigns | `https://hostwizard.lu/admin/prospecting/campaigns` | +| API - Prospects | `GET https://hostwizard.lu/api/admin/prospecting/prospects` | +| API - Leads | `GET https://hostwizard.lu/api/admin/prospecting/leads` | +| API - Stats | `GET https://hostwizard.lu/api/admin/prospecting/stats` | + +--- + +## Data Model + +### Prospect + +``` +Prospect +├── id (PK) +├── channel: DIGITAL | OFFLINE +├── business_name (str) +├── domain_name (str, unique) +├── status: PENDING | ACTIVE | INACTIVE | PARKED | ERROR | CONTACTED | CONVERTED +├── source (str) +├── Digital fields: has_website, uses_https, http_status_code, redirect_url, scan timestamps +├── Offline fields: address, city, postal_code, country, location_lat/lng, captured_by_user_id +├── notes, tags (JSON) +├── created_at, updated_at +└── Relationships: tech_profile, performance_profile, score, contacts, interactions +``` + +### Prospect Score (0-100) + +``` +ProspectScore +├── score (0-100, overall) +├── Components: technical_health (max 40), modernity (max 25), business_value (max 25), engagement (max 10) +├── reason_flags (JSON array) +├── score_breakdown (JSON dict) +└── lead_tier: top_priority | quick_win | strategic | low_priority +``` + +### Status Flow + +``` +PENDING + ↓ (HTTP check determines website status) +ACTIVE (has website) or PARKED (no website / parked domain) + ↓ (contact attempt) +CONTACTED + ↓ (outcome) +CONVERTED (sale) or INACTIVE (not interested) + +Alternative: PENDING → ERROR (invalid domain, technical issues) +``` + +--- + +## User Journeys + +### Journey 1: Digital Lead Discovery (Domain Scanning) + +**Persona:** Platform Admin +**Goal:** Import `.lu` domains, enrich them, and identify sales opportunities + +```mermaid +flowchart TD + A[Import CSV of .lu domains] --> B[Prospects created with status PENDING] + B --> C[Run HTTP check batch] + C --> D[Run tech scan batch] + D --> E[Run performance scan batch] + E --> F[Run contact scrape] + F --> G[Compute scores batch] + G --> H[View scored leads] + H --> I{Score tier?} + I -->|>= 70: Top Priority| J[Export & contact immediately] + I -->|50-69: Quick Win| K[Queue for campaign] + I -->|30-49: Strategic| L[Monitor & nurture] + I -->|< 30: Low Priority| M[Deprioritize] +``` + +**Steps:** + +1. Navigate to Dashboard: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting` + - Prod: `https://hostwizard.lu/admin/prospecting` +2. Import domains via CSV: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/import` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/import` +3. Run HTTP batch check: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/batch` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/http-check/batch` +4. Run tech scan batch: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/batch` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/tech-scan/batch` +5. Run performance scan batch: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/batch` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/performance/batch` +6. Compute scores: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/score-compute/batch` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/score-compute/batch` +7. Monitor scan jobs: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/scan-jobs` + - Prod: `https://hostwizard.lu/admin/prospecting/scan-jobs` +8. View scored leads: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/leads` + - Prod: `https://hostwizard.lu/admin/prospecting/leads` +9. Export top priority leads: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv?min_score=70` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/export/csv?min_score=70` + +--- + +### Journey 2: Offline Lead Capture + +**Persona:** Platform Admin (out in the field) +**Goal:** Capture business details from in-person encounters using the mobile-friendly capture form + +```mermaid +flowchart TD + A[Meet business owner in-person] --> B[Open Quick Capture on mobile] + B --> C[Enter business name, address, contact info] + C --> D[Prospect created with channel=OFFLINE] + D --> E{Has website?} + E -->|Yes| F[Run full enrichment] + E -->|No| G[Score based on business value only] + F --> H[Prospect fully enriched with score] + G --> H + H --> I[Log interaction: VISIT] + I --> J[Set follow-up date] +``` + +**Steps:** + +1. Open Quick Capture (mobile-friendly): + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/capture` + - Prod: `https://hostwizard.lu/admin/prospecting/capture` +2. Fill in business details and submit: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects` + - Body includes: `channel: "offline"`, `business_name`, `address`, `city`, `postal_code` +3. Optionally run full enrichment (if domain known): + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{id}` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/full/{id}` +4. Log the interaction: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions` + - Body: `{ "interaction_type": "visit", "notes": "Met at trade fair", "next_action": "Send proposal", "next_action_date": "2026-03-10" }` + +--- + +### Journey 3: Lead Qualification & Export + +**Persona:** Platform Admin +**Goal:** Filter enriched prospects by score tier and export qualified leads for outreach + +```mermaid +flowchart TD + A[Navigate to Leads page] --> B[Filter by score tier] + B --> C{View preset lists} + C -->|Top Priority| D[Score >= 70] + C -->|Quick Wins| E[Score 50-69] + C -->|Custom filter| F[Set min/max score, channel, contact type] + D --> G[Review leads] + E --> G + F --> G + G --> H[Export as CSV] + H --> I[Use in campaigns or CRM] +``` + +**Steps:** + +1. Navigate to Leads: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/leads` + - Prod: `https://hostwizard.lu/admin/prospecting/leads` +2. View top priority leads: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/top-priority` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/top-priority` +3. View quick wins: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/quick-wins` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/quick-wins` +4. Filter with custom parameters: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads?min_score=60&has_email=true&channel=digital` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads?min_score=60&has_email=true&channel=digital` +5. Export filtered leads as CSV: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv?min_score=50&lead_tier=quick_win` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/export/csv?min_score=50&lead_tier=quick_win` + +--- + +### Journey 4: Campaign Creation & Outreach + +**Persona:** Platform Admin +**Goal:** Create email campaign templates and send targeted outreach to qualified prospects + +```mermaid +flowchart TD + A[Navigate to Campaigns] --> B[Create campaign template] + B --> C[Choose lead type: no_website, bad_website, etc.] + C --> D[Write email template with variables] + D --> E[Preview rendered for specific prospect] + E --> F{Looks good?} + F -->|Yes| G[Select qualifying leads] + G --> H[Send campaign] + H --> I[Monitor send status] + F -->|No| J[Edit template] + J --> E +``` + +**Steps:** + +1. Navigate to Campaigns: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/campaigns` + - Prod: `https://hostwizard.lu/admin/prospecting/campaigns` +2. Create a campaign template: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/templates` + - Body: `{ "name": "No Website Outreach", "lead_type": "no_website", "channel": "email", "language": "fr", "subject_template": "Votre presence en ligne", "body_template": "Bonjour {{business_name}}..." }` +3. Preview the template for a specific prospect: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/preview` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/preview` + - Body: `{ "template_id": 1, "prospect_id": 42 }` +4. Send campaign to selected prospects: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/send` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/send` + - Body: `{ "template_id": 1, "prospect_ids": [42, 43, 44] }` +5. Monitor campaign sends: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/sends` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/campaigns/sends` + +--- + +### Journey 5: Interaction Tracking & Follow-ups + +**Persona:** Platform Admin +**Goal:** Log interactions with prospects and track follow-up actions + +```mermaid +flowchart TD + A[Open prospect detail] --> B[View interaction history] + B --> C[Log new interaction] + C --> D[Set next action & date] + D --> E[View upcoming follow-ups] + E --> F[Complete follow-up] + F --> G{Positive outcome?} + G -->|Yes| H[Mark as CONTACTED → CONVERTED] + G -->|No| I[Schedule next follow-up or mark INACTIVE] +``` + +**Steps:** + +1. View prospect detail: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{id}` + - Prod: `https://hostwizard.lu/admin/prospecting/prospects/{id}` +2. View interactions: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions` +3. Log a new interaction: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions` + - Body: `{ "interaction_type": "call", "subject": "Follow-up call", "outcome": "positive", "next_action": "Send proposal", "next_action_date": "2026-03-15" }` +4. View upcoming follow-ups across all prospects: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/interactions/upcoming` + - API Prod: `GET https://hostwizard.lu/api/admin/prospecting/interactions/upcoming` + +--- + +### Journey 6: Full Enrichment Pipeline (Single Prospect) + +**Persona:** Platform Admin +**Goal:** Run the complete enrichment pipeline for a single prospect to get all data at once + +```mermaid +flowchart TD + A[Open prospect detail] --> B[Click 'Full Enrichment'] + B --> C[Step 1: HTTP check] + C --> D{Has website?} + D -->|Yes| E[Step 2: Tech scan] + D -->|No| H[Step 5: Compute score] + E --> F[Step 3: Performance audit] + F --> G[Step 4: Contact scrape] + G --> H + H --> I[Prospect fully enriched] + I --> J[View score & breakdown] +``` + +**Steps:** + +1. Run full enrichment for a prospect: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{prospect_id}` + - API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/full/{prospect_id}` +2. View the enriched prospect detail: + - Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{prospect_id}` + - Prod: `https://hostwizard.lu/admin/prospecting/prospects/{prospect_id}` + +The full enrichment runs 5 sequential steps: + +1. **HTTP check** — Verifies domain connectivity, checks HTTPS, records redirects +2. **Tech scan** — Detects CMS, server, hosting provider, JS framework, SSL cert details +3. **Performance audit** — Runs PageSpeed analysis, records load times and scores +4. **Contact scrape** — Extracts emails, phones, addresses, social links from the website +5. **Score compute** — Calculates 0-100 opportunity score with component breakdown + +--- + +## Recommended Test Order + +1. **Journey 1** (steps 1-3) - Import domains and run HTTP checks first +2. **Journey 6** - Run full enrichment on a single prospect to test the complete pipeline +3. **Journey 1** (steps 4-9) - Run batch scans and view scored leads +4. **Journey 2** - Test offline capture on mobile +5. **Journey 3** - Filter and export leads +6. **Journey 4** - Create campaign templates and send to prospects +7. **Journey 5** - Log interactions and check follow-ups + +!!! tip "Enrichment order matters" + The enrichment pipeline must run in order: HTTP check first (determines if website exists), + then tech scan, performance, and contacts (all require a live website). Score computation + should run last as it uses data from all other steps. diff --git a/mkdocs.yml b/mkdocs.yml index f8009427..54d0260a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -277,6 +277,8 @@ nav: - Subscription & Billing: features/subscription-billing.md - Email System: features/email-system.md - User Journeys: + - Prospecting: features/user-journeys/prospecting.md + - Hosting: features/user-journeys/hosting.md - Loyalty: features/user-journeys/loyalty.md # --- User Guides --- diff --git a/scripts/seed/init_production.py b/scripts/seed/init_production.py index 1de690c4..b69267d1 100644 --- a/scripts/seed/init_production.py +++ b/scripts/seed/init_production.py @@ -55,6 +55,7 @@ for _mod in [ "app.modules.orders.models", "app.modules.marketplace.models", "app.modules.cms.models", + "app.modules.hosting.models", ]: with contextlib.suppress(ImportError): __import__(_mod) @@ -194,6 +195,16 @@ def create_oms_admin(db: Session, auth_manager: AuthManager, oms_platform: Platf ) +def create_hostwizard_admin(db: Session, auth_manager: AuthManager, hosting_platform: Platform) -> User | None: + """Create a platform admin for the HostWizard platform.""" + return create_platform_admin( + db, auth_manager, hosting_platform, + username="hosting_admin", + email="admin@hostwizard.lu", + first_name="HostWizard", + ) + + def create_default_platforms(db: Session) -> list[Platform]: """Create all default platforms (OMS, Main, Loyalty+).""" @@ -231,6 +242,17 @@ def create_default_platforms(db: Session) -> list[Platform]: "settings": {"features": ["points", "rewards", "tiers", "analytics"]}, "theme_config": {"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"}, }, + { + "code": "hosting", + "name": "HostWizard", + "description": "Web hosting, domains, email, and website building for Luxembourg businesses", + "domain": "hostwizard.lu", + "path_prefix": "hosting", + "default_language": "fr", + "supported_languages": ["fr", "de", "en", "lb"], + "settings": {"features": ["hosting", "domains", "email", "ssl", "poc_sites"]}, + "theme_config": {"primary_color": "#0D9488", "secondary_color": "#14B8A6"}, + }, ] platforms = [] @@ -526,6 +548,7 @@ def create_platform_modules(db: Session, platforms: list[Platform]) -> int: "oms": ["inventory", "catalog", "orders", "marketplace", "analytics", "cart", "checkout"], "main": ["analytics", "monitoring", "dev-tools"], "loyalty": ["loyalty"], + "hosting": ["prospecting", "hosting", "analytics"], } now = datetime.now(UTC) @@ -647,6 +670,12 @@ def initialize_production(db: Session, auth_manager: AuthManager): else: print_warning("Loyalty platform not found, skipping loyalty admin creation") + hosting_platform = next((p for p in platforms if p.code == "hosting"), None) + if hosting_platform: + create_hostwizard_admin(db, auth_manager, hosting_platform) + else: + print_warning("Hosting platform not found, skipping hosting admin creation") + # Step 4: Set up default role templates print_step(4, "Setting up role templates...") create_default_role_templates(db) @@ -729,6 +758,11 @@ def print_summary(db: Session): print(f" URL: {admin_url}") print(" Username: loyalty_admin") print(" Password: admin123") # noqa: SEC021 + print() + print(" HostWizard Platform Admin (hosting only):") + print(f" URL: {admin_url}") + print(" Username: hosting_admin") + print(" Password: admin123") # noqa: SEC021 print("─" * 70) # Show security warnings if in production