feat: module-driven onboarding system + simplified 3-step signup

Add OnboardingProviderProtocol so modules declare their own post-signup
onboarding steps. The core OnboardingAggregator discovers enabled
providers and exposes a dashboard API (GET /dashboard/onboarding).
A session-scoped banner on the store dashboard shows a checklist that
guides merchants through setup without blocking signup.

Signup is simplified from 4 steps to 3 (Plan → Account → Payment):
store creation is merged into account creation, store language is
captured from the user's browsing language, and platform-specific
template branching is removed.

Includes 47 unit and integration tests covering all new providers,
the aggregator, the API endpoint, and the signup service changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 23:39:42 +01:00
parent f8a2394da5
commit ef9ea29643
26 changed files with 2055 additions and 699 deletions

View File

@@ -24,6 +24,7 @@ from app.modules.core.schemas.dashboard import (
StoreProductStats,
StoreRevenueStats,
)
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.exceptions import StoreNotActiveException
from app.modules.tenancy.schemas.auth import UserContext
@@ -124,3 +125,27 @@ def get_store_dashboard_stats(
this_month=float(revenue_this_month),
),
)
@store_dashboard_router.get("/onboarding")
def get_onboarding_status(
request: Request,
current_user: UserContext = Depends(get_current_store_api),
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
Get onboarding checklist status for the current store.
Returns steps from all enabled modules with completion status,
progress percentage, and whether all steps are completed.
"""
store_id = current_user.token_store_id
store = store_service.get_store_by_id(db, store_id)
return onboarding_aggregator.get_onboarding_summary(
db=db,
store_id=store_id,
platform_id=platform.id,
store_code=store.store_code,
)

View File

@@ -0,0 +1,157 @@
# app/modules/core/services/onboarding_aggregator.py
"""
Onboarding aggregator service for collecting onboarding steps from all modules.
This service lives in core because the dashboard onboarding banner is core
functionality. It discovers OnboardingProviders from all enabled modules and
provides a unified interface for the dashboard checklist.
Usage:
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
summary = onboarding_aggregator.get_onboarding_summary(
db=db, store_id=123, platform_id=1, store_code="my-store"
)
# Returns: {steps, total_steps, completed_steps, progress_percentage, all_completed}
"""
import logging
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import (
OnboardingProviderProtocol,
OnboardingStepStatus,
)
if TYPE_CHECKING:
from app.modules.base import ModuleDefinition
logger = logging.getLogger(__name__)
class OnboardingAggregatorService:
"""
Aggregates onboarding steps from all module providers.
Discovers OnboardingProviders from enabled modules and provides
a unified interface for the dashboard onboarding banner.
"""
def _get_enabled_providers(
self, db: Session, platform_id: int
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
"""
Get onboarding providers from enabled modules.
Returns:
List of (module, provider) tuples for enabled modules with providers
"""
from app.modules.registry import MODULES
from app.modules.service import module_service
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
for module in MODULES.values():
if not module.has_onboarding_provider():
continue
# Core modules are always enabled, check others
if not module.is_core:
try:
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
except Exception as e:
logger.warning(
f"Failed to check if module {module.code} is enabled: {e}"
)
continue
try:
provider = module.get_onboarding_provider_instance()
if provider is not None:
providers.append((module, provider))
except Exception as e:
logger.warning(
f"Failed to get onboarding provider for module {module.code}: {e}"
)
return providers
def get_onboarding_steps(
self, db: Session, store_id: int, platform_id: int
) -> list[OnboardingStepStatus]:
"""
Get all onboarding steps with completion status, sorted by order.
Returns:
Sorted list of OnboardingStepStatus objects
"""
providers = self._get_enabled_providers(db, platform_id)
steps: list[OnboardingStepStatus] = []
for module, provider in providers:
try:
step_defs = provider.get_onboarding_steps()
for step_def in step_defs:
try:
completed = provider.is_step_completed(db, store_id, step_def.key)
except Exception as e:
logger.warning(
f"Failed to check step {step_def.key} from {module.code}: {e}"
)
completed = False
steps.append(OnboardingStepStatus(step=step_def, completed=completed))
except Exception as e:
logger.warning(
f"Failed to get onboarding steps from module {module.code}: {e}"
)
steps.sort(key=lambda s: s.step.order)
return steps
def get_onboarding_summary(
self,
db: Session,
store_id: int,
platform_id: int,
store_code: str = "",
) -> dict[str, Any]:
"""
Get onboarding summary for the dashboard API.
Returns:
Dict with: steps[], total_steps, completed_steps, progress_percentage, all_completed
"""
step_statuses = self.get_onboarding_steps(db, store_id, platform_id)
total = len(step_statuses)
completed = sum(1 for s in step_statuses if s.completed)
steps_data = []
for status in step_statuses:
route = status.step.route_template.replace("{store_code}", store_code)
steps_data.append({
"key": status.step.key,
"title_key": status.step.title_key,
"description_key": status.step.description_key,
"icon": status.step.icon,
"route": route,
"completed": status.completed,
"category": status.step.category,
})
return {
"steps": steps_data,
"total_steps": total,
"completed_steps": completed,
"progress_percentage": round((completed / total * 100) if total > 0 else 0),
"all_completed": completed == total and total > 0,
}
# Singleton instance
onboarding_aggregator = OnboardingAggregatorService()
__all__ = ["OnboardingAggregatorService", "onboarding_aggregator"]

View File

@@ -10,6 +10,47 @@ const storeDashLog = window.LogConfig.loggers.dashboard ||
storeDashLog.info('Loading...');
storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
/**
* Onboarding banner component.
* Fetches onboarding steps from API, supports session-scoped dismiss.
*/
function onboardingBanner() {
return {
visible: false,
steps: [],
totalSteps: 0,
completedSteps: 0,
progressPercentage: 0,
async init() {
// Check session-scoped dismiss
if (sessionStorage.getItem('onboarding_dismissed')) {
return;
}
try {
const response = await apiClient.get('/store/dashboard/onboarding');
this.steps = response.steps || [];
this.totalSteps = response.total_steps || 0;
this.completedSteps = response.completed_steps || 0;
this.progressPercentage = response.progress_percentage || 0;
// Show banner only if there are incomplete steps
if (this.totalSteps > 0 && !response.all_completed) {
this.visible = true;
}
} catch (error) {
storeDashLog.error('Failed to load onboarding status', error);
}
},
dismiss() {
sessionStorage.setItem('onboarding_dismissed', 'true');
this.visible = false;
}
};
}
function storeDashboard() {
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);

View File

@@ -8,12 +8,15 @@
{% block alpine_data %}storeDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
{% block content %}
<!-- Email Settings Warning -->
{{ email_settings_warning() }}
<!-- Onboarding Banner -->
{{ onboarding_banner() }}
<!-- Limit Warnings -->
{{ limit_warning("orders") }}
{{ limit_warning("products") }}

View File

@@ -0,0 +1,215 @@
# app/modules/core/tests/integration/test_onboarding_routes.py
"""
Integration tests for the onboarding API endpoint.
Tests the store dashboard onboarding endpoint at:
GET /api/v1/store/dashboard/onboarding
Verifies:
- Endpoint returns onboarding steps with completion status
- Endpoint requires store authentication
- Response structure matches expected schema
"""
import uuid
import pytest
from app.api.deps import get_current_store_api
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def onb_owner(db):
"""Create a store owner user for onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"onbroute_{uuid.uuid4().hex[:8]}@test.com",
username=f"onbroute_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def onb_platform(db):
"""Create a platform for onboarding tests."""
platform = Platform(
code=f"onbp_{uuid.uuid4().hex[:8]}",
name="Onboarding Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def onb_merchant(db, onb_owner):
"""Create a merchant for onboarding tests."""
merchant = Merchant(
name="Onboarding Route Test Merchant",
owner_user_id=onb_owner.id,
contact_email=onb_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def onb_store(db, onb_merchant):
"""Create a store for onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=onb_merchant.id,
store_code=f"ONBROUTE_{uid.upper()}",
subdomain=f"onbroute{uid.lower()}",
name="Onboarding Route Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def onb_store_platform(db, onb_store, onb_platform):
"""Link store to platform."""
sp = StorePlatform(
store_id=onb_store.id,
platform_id=onb_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def onb_auth(onb_owner, onb_store):
"""Override auth dependency for store API auth."""
user_context = UserContext(
id=onb_owner.id,
email=onb_owner.email,
username=onb_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=onb_store.id,
token_store_code=onb_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_api] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_api, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestOnboardingEndpoint:
"""Tests for GET /api/v1/store/dashboard/onboarding."""
def test_returns_onboarding_summary(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Endpoint returns onboarding summary with expected structure."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
assert "steps" in data
assert "total_steps" in data
assert "completed_steps" in data
assert "progress_percentage" in data
assert "all_completed" in data
assert isinstance(data["steps"], list)
assert isinstance(data["total_steps"], int)
assert isinstance(data["completed_steps"], int)
def test_includes_tenancy_step(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Response includes the tenancy customize_store step (always present)."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
step_keys = [s["key"] for s in data["steps"]]
assert "tenancy.customize_store" in step_keys
def test_step_data_has_required_fields(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Each step has key, title_key, description_key, icon, route, completed."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "key" in step
assert "title_key" in step
assert "description_key" in step
assert "icon" in step
assert "route" in step
assert "completed" in step
assert isinstance(step["completed"], bool)
def test_routes_contain_store_code(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Step routes have {store_code} replaced with the actual store code."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "{store_code}" not in step["route"]
if "/store/" in step["route"]:
assert onb_store.store_code in step["route"]
def test_requires_auth(self, client):
"""Returns 401 without authentication."""
app.dependency_overrides.pop(get_current_store_api, None)
response = client.get(
"/api/v1/store/dashboard/onboarding",
)
assert response.status_code == 401

View File

@@ -0,0 +1,275 @@
# app/modules/core/tests/unit/test_onboarding_aggregator.py
"""Unit tests for OnboardingAggregatorService."""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.contracts.onboarding import (
OnboardingStepDefinition,
OnboardingStepStatus,
)
from app.modules.core.services.onboarding_aggregator import OnboardingAggregatorService
def _make_step(key: str, order: int = 100, category: str = "test") -> OnboardingStepDefinition:
return OnboardingStepDefinition(
key=key,
title_key=f"{key}.title",
description_key=f"{key}.desc",
icon="circle",
route_template="/store/{store_code}/test",
order=order,
category=category,
)
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSteps:
"""Tests for OnboardingAggregatorService.get_onboarding_steps."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_aggregates_steps_from_multiple_providers(self, db):
"""Collects steps from all enabled module providers."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 2
assert steps[0].step.key == "tenancy.customize"
assert steps[0].completed is True
assert steps[1].step.key == "marketplace.connect"
assert steps[1].completed is False
def test_steps_sorted_by_order(self, db):
"""Steps are returned sorted by order regardless of provider order."""
step_high = _make_step("loyalty.program", order=300)
step_low = _make_step("tenancy.customize", order=100)
provider_high = MagicMock()
provider_high.onboarding_category = "loyalty"
provider_high.get_onboarding_steps.return_value = [step_high]
provider_high.is_step_completed.return_value = False
provider_low = MagicMock()
provider_low.onboarding_category = "tenancy"
provider_low.get_onboarding_steps.return_value = [step_low]
provider_low.is_step_completed.return_value = False
module_high = MagicMock()
module_high.code = "loyalty"
module_low = MagicMock()
module_low.code = "tenancy"
# Provide high-order first
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_high, provider_high), (module_low, provider_low)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps[0].step.key == "tenancy.customize"
assert steps[1].step.key == "loyalty.program"
def test_empty_when_no_providers(self, db):
"""Returns empty list when no modules have onboarding providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_provider_error_gracefully(self, db):
"""Skips a provider that raises an exception."""
provider = MagicMock()
provider.onboarding_category = "broken"
provider.get_onboarding_steps.side_effect = RuntimeError("DB error")
module = MagicMock()
module.code = "broken"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_step_completion_check_error(self, db):
"""Marks step as incomplete when completion check raises."""
step = _make_step("test.step")
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.side_effect = RuntimeError("query error")
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 1
assert steps[0].completed is False
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSummary:
"""Tests for OnboardingAggregatorService.get_onboarding_summary."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_summary_with_mixed_completion(self, db):
"""Returns correct progress for partially completed onboarding."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
step_c = _make_step("marketplace.import", order=210, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b, step_c]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MYSTORE"
)
assert summary["total_steps"] == 3
assert summary["completed_steps"] == 1
assert summary["progress_percentage"] == 33
assert summary["all_completed"] is False
assert len(summary["steps"]) == 3
def test_summary_all_completed(self, db):
"""Returns all_completed=True when every step is done."""
step = _make_step("tenancy.customize")
provider = MagicMock()
provider.onboarding_category = "tenancy"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "tenancy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["all_completed"] is True
assert summary["progress_percentage"] == 100
def test_summary_empty_providers(self, db):
"""Returns zeros and all_completed=False when no providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["total_steps"] == 0
assert summary["completed_steps"] == 0
assert summary["progress_percentage"] == 0
assert summary["all_completed"] is False
def test_summary_replaces_store_code_in_routes(self, db):
"""Route templates have {store_code} replaced with actual store code."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="circle",
route_template="/store/{store_code}/settings",
order=100,
category="test",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = False
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MY_STORE"
)
assert summary["steps"][0]["route"] == "/store/MY_STORE/settings"
def test_summary_step_data_includes_all_fields(self, db):
"""Each step in summary includes key, title_key, description_key, icon, route, completed, category."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="settings",
route_template="/store/{store_code}/test",
order=100,
category="test_cat",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="S1"
)
step_data = summary["steps"][0]
assert step_data["key"] == "test.step"
assert step_data["title_key"] == "test.title"
assert step_data["description_key"] == "test.desc"
assert step_data["icon"] == "settings"
assert step_data["route"] == "/store/S1/test"
assert step_data["completed"] is True
assert step_data["category"] == "test_cat"