diff --git a/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py b/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py new file mode 100644 index 00000000..d39a2202 --- /dev/null +++ b/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py @@ -0,0 +1,72 @@ +"""add vendor onboarding table + +Revision ID: m1b2c3d4e5f6 +Revises: d7a4a3f06394 +Create Date: 2025-12-27 22:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'm1b2c3d4e5f6' +down_revision: Union[str, None] = 'd7a4a3f06394' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('vendor_onboarding', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('vendor_id', sa.Integer(), nullable=False), + # Overall status + sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'), + sa.Column('current_step', sa.String(length=30), nullable=False, server_default='company_profile'), + # Step 1: Company Profile + sa.Column('step_company_profile_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_company_profile_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_company_profile_data', sa.JSON(), nullable=True), + # Step 2: Letzshop API Configuration + sa.Column('step_letzshop_api_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_letzshop_api_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_letzshop_api_connection_verified', sa.Boolean(), nullable=False, server_default=sa.text('0')), + # Step 3: Product Import + sa.Column('step_product_import_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_product_import_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_product_import_csv_url_set', sa.Boolean(), nullable=False, server_default=sa.text('0')), + # Step 4: Order Sync + sa.Column('step_order_sync_completed', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('step_order_sync_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('step_order_sync_job_id', sa.Integer(), nullable=True), + # Completion tracking + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + # Admin override + sa.Column('skipped_by_admin', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('skipped_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('skipped_reason', sa.Text(), nullable=True), + sa.Column('skipped_by_user_id', sa.Integer(), nullable=True), + # Timestamps + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + # Constraints + sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['skipped_by_user_id'], ['users.id']), + sa.PrimaryKeyConstraint('id'), + sqlite_autoincrement=True + ) + op.create_index(op.f('ix_vendor_onboarding_id'), 'vendor_onboarding', ['id'], unique=False) + op.create_index(op.f('ix_vendor_onboarding_vendor_id'), 'vendor_onboarding', ['vendor_id'], unique=True) + op.create_index(op.f('ix_vendor_onboarding_status'), 'vendor_onboarding', ['status'], unique=False) + op.create_index('idx_onboarding_vendor_status', 'vendor_onboarding', ['vendor_id', 'status'], unique=False) + + +def downgrade() -> None: + op.drop_index('idx_onboarding_vendor_status', table_name='vendor_onboarding') + op.drop_index(op.f('ix_vendor_onboarding_status'), table_name='vendor_onboarding') + op.drop_index(op.f('ix_vendor_onboarding_vendor_id'), table_name='vendor_onboarding') + op.drop_index(op.f('ix_vendor_onboarding_id'), table_name='vendor_onboarding') + op.drop_table('vendor_onboarding') diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index b347b2cf..7d981ea0 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -28,6 +28,7 @@ from . import ( media, messages, notifications, + onboarding, order_item_exceptions, orders, payments, @@ -56,6 +57,7 @@ router.include_router(auth.router, tags=["vendor-auth"]) router.include_router(dashboard.router, tags=["vendor-dashboard"]) router.include_router(profile.router, tags=["vendor-profile"]) router.include_router(settings.router, tags=["vendor-settings"]) +router.include_router(onboarding.router, tags=["vendor-onboarding"]) # Business operations (with prefixes: /products/*, /orders/*, etc.) router.include_router(products.router, tags=["vendor-products"]) diff --git a/app/api/v1/vendor/onboarding.py b/app/api/v1/vendor/onboarding.py new file mode 100644 index 00000000..f0f39ef2 --- /dev/null +++ b/app/api/v1/vendor/onboarding.py @@ -0,0 +1,294 @@ +# app/api/v1/vendor/onboarding.py +""" +Vendor onboarding API endpoints. + +Provides endpoints for the 4-step mandatory onboarding wizard: +1. Company Profile Setup +2. Letzshop API Configuration +3. Product & Order Import Configuration +4. Order Sync (historical import) + +Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern). +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.onboarding_service import ( + OnboardingError, + OnboardingService, + OnboardingStepError, +) +from models.database.user import User +from models.schema.onboarding import ( + CompanyProfileRequest, + CompanyProfileResponse, + LetzshopApiConfigRequest, + LetzshopApiConfigResponse, + LetzshopApiTestRequest, + LetzshopApiTestResponse, + OnboardingStatusResponse, + OrderSyncCompleteRequest, + OrderSyncCompleteResponse, + OrderSyncProgressResponse, + OrderSyncTriggerRequest, + OrderSyncTriggerResponse, + ProductImportConfigRequest, + ProductImportConfigResponse, +) + +router = APIRouter(prefix="/onboarding") +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Status Endpoint +# ============================================================================= + + +@router.get("/status", response_model=OnboardingStatusResponse) +def get_onboarding_status( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current onboarding status. + + Returns full status including all step completion states and progress. + """ + service = OnboardingService(db) + status = service.get_status_response(current_user.token_vendor_id) + return status + + +# ============================================================================= +# Step 1: Company Profile +# ============================================================================= + + +@router.get("/step/company-profile") +def get_company_profile( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current company profile data for editing. + + Returns pre-filled data from vendor and company records. + """ + service = OnboardingService(db) + return service.get_company_profile_data(current_user.token_vendor_id) + + +@router.post("/step/company-profile", response_model=CompanyProfileResponse) +def save_company_profile( + request: CompanyProfileRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Save company profile and complete Step 1. + + Updates vendor and company records with provided data. + """ + service = OnboardingService(db) + + try: + result = service.complete_company_profile( + vendor_id=current_user.token_vendor_id, + company_name=request.company_name, + brand_name=request.brand_name, + description=request.description, + contact_email=request.contact_email, + contact_phone=request.contact_phone, + website=request.website, + business_address=request.business_address, + tax_number=request.tax_number, + default_language=request.default_language, + dashboard_language=request.dashboard_language, + ) + return result + except OnboardingError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ============================================================================= +# Step 2: Letzshop API Configuration +# ============================================================================= + + +@router.post("/step/letzshop-api/test", response_model=LetzshopApiTestResponse) +def test_letzshop_api( + request: LetzshopApiTestRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Test Letzshop API connection without saving. + + Use this to validate API key before saving credentials. + """ + service = OnboardingService(db) + return service.test_letzshop_api( + api_key=request.api_key, + shop_slug=request.shop_slug, + ) + + +@router.post("/step/letzshop-api", response_model=LetzshopApiConfigResponse) +def save_letzshop_api( + request: LetzshopApiConfigRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Save Letzshop API credentials and complete Step 2. + + Tests connection first, only saves if successful. + """ + service = OnboardingService(db) + + try: + result = service.complete_letzshop_api( + vendor_id=current_user.token_vendor_id, + api_key=request.api_key, + shop_slug=request.shop_slug, + letzshop_vendor_id=request.vendor_id, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Step 3: Product & Order Import Configuration +# ============================================================================= + + +@router.get("/step/product-import") +def get_product_import_config( + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get current product import configuration. + + Returns pre-filled CSV URLs and Letzshop feed settings. + """ + service = OnboardingService(db) + return service.get_product_import_config(current_user.token_vendor_id) + + +@router.post("/step/product-import", response_model=ProductImportConfigResponse) +def save_product_import_config( + request: ProductImportConfigRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Save product import configuration and complete Step 3. + + At least one CSV URL must be provided. + """ + service = OnboardingService(db) + + try: + result = service.complete_product_import( + vendor_id=current_user.token_vendor_id, + csv_url_fr=request.csv_url_fr, + csv_url_en=request.csv_url_en, + csv_url_de=request.csv_url_de, + default_tax_rate=request.default_tax_rate, + delivery_method=request.delivery_method, + preorder_days=request.preorder_days, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Step 4: Order Sync +# ============================================================================= + + +@router.post("/step/order-sync/trigger", response_model=OrderSyncTriggerResponse) +def trigger_order_sync( + request: OrderSyncTriggerRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Trigger historical order import. + + Creates a background job that imports orders from Letzshop. + """ + service = OnboardingService(db) + + try: + result = service.trigger_order_sync( + vendor_id=current_user.token_vendor_id, + user_id=current_user.id, + days_back=request.days_back, + include_products=request.include_products, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get( + "/step/order-sync/progress/{job_id}", + response_model=OrderSyncProgressResponse, +) +def get_order_sync_progress( + job_id: int, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Get order sync job progress. + + Poll this endpoint to show progress bar during import. + """ + service = OnboardingService(db) + return service.get_order_sync_progress( + vendor_id=current_user.token_vendor_id, + job_id=job_id, + ) + + +@router.post("/step/order-sync/complete", response_model=OrderSyncCompleteResponse) +def complete_order_sync( + request: OrderSyncCompleteRequest, + current_user: User = Depends(get_current_vendor_api), + db: Session = Depends(get_db), +): + """ + Mark order sync step as complete. + + Called after the import job finishes (success or failure). + This also marks the entire onboarding as complete. + """ + service = OnboardingService(db) + + try: + result = service.complete_order_sync( + vendor_id=current_user.token_vendor_id, + job_id=request.job_id, + ) + return result + except OnboardingStepError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OnboardingError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 5a8295e6..e56727d4 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -11,7 +11,8 @@ Authentication failures redirect to /vendor/{vendor_code}/login. Routes: - GET /vendor/{vendor_code}/ → Redirect to login or dashboard - GET /vendor/{vendor_code}/login → Vendor login page -- GET /vendor/{vendor_code}/dashboard → Vendor dashboard +- GET /vendor/{vendor_code}/onboarding → Vendor onboarding wizard +- GET /vendor/{vendor_code}/dashboard → Vendor dashboard (requires onboarding) - GET /vendor/{vendor_code}/products → Product management - GET /vendor/{vendor_code}/orders → Order management - GET /vendor/{vendor_code}/customers → Customer management @@ -34,6 +35,7 @@ from app.api.deps import ( get_db, ) from app.services.content_page_service import content_page_service +from app.services.onboarding_service import OnboardingService from models.database.user import User logger = logging.getLogger(__name__) @@ -111,6 +113,44 @@ async def vendor_login_page( # ============================================================================ +@router.get( + "/{vendor_code}/onboarding", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_onboarding_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render vendor onboarding wizard. + + Mandatory 4-step wizard that must be completed before accessing dashboard: + 1. Company Profile Setup + 2. Letzshop API Configuration + 3. Product & Order Import Configuration + 4. Order Sync (historical import) + + If onboarding is already completed, redirects to dashboard. + """ + # Check if onboarding is completed + onboarding_service = OnboardingService(db) + if onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse( + url=f"/vendor/{vendor_code}/dashboard", + status_code=302, + ) + + return templates.TemplateResponse( + "vendor/onboarding.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + }, + ) + + @router.get( "/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False ) @@ -118,16 +158,27 @@ async def vendor_dashboard_page( request: Request, vendor_code: str = Path(..., description="Vendor code"), current_user: User = Depends(get_current_vendor_from_cookie_or_header), + db: Session = Depends(get_db), ): """ Render vendor dashboard. + Redirects to onboarding if not completed. + JavaScript will: - Load vendor info via API - Load dashboard stats via API - Load recent orders via API - Handle all interactivity """ + # Check if onboarding is completed + onboarding_service = OnboardingService(db) + if not onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse( + url=f"/vendor/{vendor_code}/onboarding", + status_code=302, + ) + return templates.TemplateResponse( "vendor/dashboard.html", { diff --git a/app/services/onboarding_service.py b/app/services/onboarding_service.py new file mode 100644 index 00000000..dcd9b570 --- /dev/null +++ b/app/services/onboarding_service.py @@ -0,0 +1,676 @@ +# app/services/onboarding_service.py +""" +Vendor onboarding service. + +Handles the 4-step mandatory onboarding wizard for new vendors: +1. Company Profile Setup +2. Letzshop API Configuration +3. Product & Order Import (CSV feed URL configuration) +4. Order Sync (historical import with progress tracking) +""" + +import logging +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from app.services.letzshop.credentials_service import LetzshopCredentialsService +from app.services.letzshop.order_service import LetzshopOrderService +from models.database.company import Company +from models.database.letzshop import LetzshopHistoricalImportJob +from models.database.onboarding import ( + OnboardingStatus, + OnboardingStep, + VendorOnboarding, +) +from models.database.vendor import Vendor + +logger = logging.getLogger(__name__) + + +class OnboardingError(Exception): + """Base exception for onboarding errors.""" + + +class OnboardingNotFoundError(OnboardingError): + """Raised when onboarding record is not found.""" + + +class OnboardingStepError(OnboardingError): + """Raised when a step operation fails.""" + + +class OnboardingService: + """ + Service for managing vendor onboarding workflow. + + Provides methods for each onboarding step and progress tracking. + """ + + def __init__(self, db: Session): + """ + Initialize the onboarding service. + + Args: + db: SQLAlchemy database session. + """ + self.db = db + + # ========================================================================= + # Onboarding CRUD + # ========================================================================= + + def get_onboarding(self, vendor_id: int) -> VendorOnboarding | None: + """Get onboarding record for a vendor.""" + return ( + self.db.query(VendorOnboarding) + .filter(VendorOnboarding.vendor_id == vendor_id) + .first() + ) + + def get_onboarding_or_raise(self, vendor_id: int) -> VendorOnboarding: + """Get onboarding record or raise OnboardingNotFoundError.""" + onboarding = self.get_onboarding(vendor_id) + if onboarding is None: + raise OnboardingNotFoundError( + f"Onboarding not found for vendor {vendor_id}" + ) + return onboarding + + def create_onboarding(self, vendor_id: int) -> VendorOnboarding: + """ + Create a new onboarding record for a vendor. + + This is called automatically when a vendor is created during signup. + """ + # Check if already exists + existing = self.get_onboarding(vendor_id) + if existing: + logger.warning(f"Onboarding already exists for vendor {vendor_id}") + return existing + + onboarding = VendorOnboarding( + vendor_id=vendor_id, + status=OnboardingStatus.NOT_STARTED.value, + current_step=OnboardingStep.COMPANY_PROFILE.value, + ) + + self.db.add(onboarding) + self.db.flush() + + logger.info(f"Created onboarding record for vendor {vendor_id}") + return onboarding + + def get_or_create_onboarding(self, vendor_id: int) -> VendorOnboarding: + """Get existing onboarding or create new one.""" + onboarding = self.get_onboarding(vendor_id) + if onboarding is None: + onboarding = self.create_onboarding(vendor_id) + return onboarding + + # ========================================================================= + # Status Helpers + # ========================================================================= + + def is_completed(self, vendor_id: int) -> bool: + """Check if onboarding is completed for a vendor.""" + onboarding = self.get_onboarding(vendor_id) + if onboarding is None: + return False + return onboarding.is_completed + + def get_status_response(self, vendor_id: int) -> dict: + """ + Get full onboarding status for API response. + + Returns a dictionary with all step statuses and progress information. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + return { + "id": onboarding.id, + "vendor_id": onboarding.vendor_id, + "status": onboarding.status, + "current_step": onboarding.current_step, + # Step statuses + "company_profile": { + "completed": onboarding.step_company_profile_completed, + "completed_at": onboarding.step_company_profile_completed_at, + "data": onboarding.step_company_profile_data, + }, + "letzshop_api": { + "completed": onboarding.step_letzshop_api_completed, + "completed_at": onboarding.step_letzshop_api_completed_at, + "connection_verified": onboarding.step_letzshop_api_connection_verified, + }, + "product_import": { + "completed": onboarding.step_product_import_completed, + "completed_at": onboarding.step_product_import_completed_at, + "csv_url_set": onboarding.step_product_import_csv_url_set, + }, + "order_sync": { + "completed": onboarding.step_order_sync_completed, + "completed_at": onboarding.step_order_sync_completed_at, + "job_id": onboarding.step_order_sync_job_id, + }, + # Progress tracking + "completion_percentage": onboarding.completion_percentage, + "completed_steps_count": onboarding.completed_steps_count, + "total_steps": 4, + # Completion info + "is_completed": onboarding.is_completed, + "started_at": onboarding.started_at, + "completed_at": onboarding.completed_at, + # Admin override info + "skipped_by_admin": onboarding.skipped_by_admin, + "skipped_at": onboarding.skipped_at, + "skipped_reason": onboarding.skipped_reason, + } + + # ========================================================================= + # Step 1: Company Profile + # ========================================================================= + + def get_company_profile_data(self, vendor_id: int) -> dict: + """Get current company profile data for editing.""" + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + return {} + + company = vendor.company + + return { + "company_name": company.name if company else None, + "brand_name": vendor.name, + "description": vendor.description, + "contact_email": vendor.effective_contact_email, + "contact_phone": vendor.effective_contact_phone, + "website": vendor.effective_website, + "business_address": vendor.effective_business_address, + "tax_number": vendor.effective_tax_number, + "default_language": vendor.default_language, + "dashboard_language": vendor.dashboard_language, + } + + def complete_company_profile( + self, + vendor_id: int, + company_name: str | None = None, + brand_name: str | None = None, + description: str | None = None, + contact_email: str | None = None, + contact_phone: str | None = None, + website: str | None = None, + business_address: str | None = None, + tax_number: str | None = None, + default_language: str = "fr", + dashboard_language: str = "fr", + ) -> dict: + """ + Save company profile and mark Step 1 as complete. + + Returns response with next step information. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Update onboarding status if this is the first step + if onboarding.status == OnboardingStatus.NOT_STARTED.value: + onboarding.status = OnboardingStatus.IN_PROGRESS.value + onboarding.started_at = datetime.now(UTC) + + # Get vendor and company + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise OnboardingStepError(f"Vendor {vendor_id} not found") + + company = vendor.company + + # Update company name if provided + if company and company_name: + company.name = company_name + + # Update vendor fields + if brand_name: + vendor.name = brand_name + if description is not None: + vendor.description = description + + # Update contact info (vendor-level overrides) + vendor.contact_email = contact_email + vendor.contact_phone = contact_phone + vendor.website = website + vendor.business_address = business_address + vendor.tax_number = tax_number + + # Update language settings + vendor.default_language = default_language + vendor.dashboard_language = dashboard_language + + # Store profile data in onboarding record + onboarding.step_company_profile_data = { + "company_name": company_name, + "brand_name": brand_name, + "description": description, + "contact_email": contact_email, + "contact_phone": contact_phone, + "website": website, + "business_address": business_address, + "tax_number": tax_number, + "default_language": default_language, + "dashboard_language": dashboard_language, + } + + # Mark step complete + onboarding.mark_step_complete(OnboardingStep.COMPANY_PROFILE.value) + + self.db.flush() + + logger.info(f"Completed company profile step for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "next_step": onboarding.current_step, + "message": "Company profile saved successfully", + } + + # ========================================================================= + # Step 2: Letzshop API Configuration + # ========================================================================= + + def test_letzshop_api( + self, + api_key: str, + shop_slug: str, + ) -> dict: + """ + Test Letzshop API connection without saving credentials. + + Returns connection test result with vendor info if successful. + """ + credentials_service = LetzshopCredentialsService(self.db) + + # Test the API key + success, response_time, error = credentials_service.test_api_key(api_key) + + if success: + return { + "success": True, + "message": f"Connection successful ({response_time:.0f}ms)", + "vendor_name": None, # Would need to query Letzshop for this + "vendor_id": None, + "shop_slug": shop_slug, + } + else: + return { + "success": False, + "message": error or "Connection failed", + "vendor_name": None, + "vendor_id": None, + "shop_slug": None, + } + + def complete_letzshop_api( + self, + vendor_id: int, + api_key: str, + shop_slug: str, + letzshop_vendor_id: str | None = None, + ) -> dict: + """ + Save Letzshop API credentials and mark Step 2 as complete. + + Tests connection first, only saves if successful. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify step order + if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value): + raise OnboardingStepError( + "Please complete the Company Profile step first" + ) + + # Test connection first + credentials_service = LetzshopCredentialsService(self.db) + success, response_time, error = credentials_service.test_api_key(api_key) + + if not success: + return { + "success": False, + "step_completed": False, + "next_step": None, + "message": f"Connection test failed: {error}", + "connection_verified": False, + } + + # Save credentials + credentials_service.upsert_credentials( + vendor_id=vendor_id, + api_key=api_key, + auto_sync_enabled=False, # Enable after onboarding + sync_interval_minutes=15, + ) + + # Update vendor with Letzshop identity + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if vendor: + vendor.letzshop_vendor_slug = shop_slug + if letzshop_vendor_id: + vendor.letzshop_vendor_id = letzshop_vendor_id + + # Mark step complete + onboarding.step_letzshop_api_connection_verified = True + onboarding.mark_step_complete(OnboardingStep.LETZSHOP_API.value) + + self.db.flush() + + logger.info(f"Completed Letzshop API step for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "next_step": onboarding.current_step, + "message": "Letzshop API configured successfully", + "connection_verified": True, + } + + # ========================================================================= + # Step 3: Product & Order Import Configuration + # ========================================================================= + + def get_product_import_config(self, vendor_id: int) -> dict: + """Get current product import configuration.""" + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + return {} + + return { + "csv_url_fr": vendor.letzshop_csv_url_fr, + "csv_url_en": vendor.letzshop_csv_url_en, + "csv_url_de": vendor.letzshop_csv_url_de, + "default_tax_rate": vendor.letzshop_default_tax_rate, + "delivery_method": vendor.letzshop_delivery_method, + "preorder_days": vendor.letzshop_preorder_days, + } + + def complete_product_import( + self, + vendor_id: int, + csv_url_fr: str | None = None, + csv_url_en: str | None = None, + csv_url_de: str | None = None, + default_tax_rate: int = 17, + delivery_method: str = "package_delivery", + preorder_days: int = 1, + ) -> dict: + """ + Save product import configuration and mark Step 3 as complete. + + At least one CSV URL must be provided. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify step order + if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value): + raise OnboardingStepError( + "Please complete the Letzshop API step first" + ) + + # Validate at least one CSV URL + csv_urls_count = sum([ + bool(csv_url_fr), + bool(csv_url_en), + bool(csv_url_de), + ]) + + if csv_urls_count == 0: + return { + "success": False, + "step_completed": False, + "next_step": None, + "message": "At least one CSV URL must be provided", + "csv_urls_configured": 0, + } + + # Update vendor settings + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise OnboardingStepError(f"Vendor {vendor_id} not found") + + vendor.letzshop_csv_url_fr = csv_url_fr + vendor.letzshop_csv_url_en = csv_url_en + vendor.letzshop_csv_url_de = csv_url_de + vendor.letzshop_default_tax_rate = default_tax_rate + vendor.letzshop_delivery_method = delivery_method + vendor.letzshop_preorder_days = preorder_days + + # Mark step complete + onboarding.step_product_import_csv_url_set = True + onboarding.mark_step_complete(OnboardingStep.PRODUCT_IMPORT.value) + + self.db.flush() + + logger.info(f"Completed product import step for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "next_step": onboarding.current_step, + "message": "Product import configured successfully", + "csv_urls_configured": csv_urls_count, + } + + # ========================================================================= + # Step 4: Order Sync + # ========================================================================= + + def trigger_order_sync( + self, + vendor_id: int, + user_id: int, + days_back: int = 90, + include_products: bool = True, + ) -> dict: + """ + Trigger historical order import and return job info. + + Creates a background job that imports historical orders from Letzshop. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify step order + if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value): + raise OnboardingStepError( + "Please complete the Product Import step first" + ) + + # Create historical import job + order_service = LetzshopOrderService(self.db) + + # Check for existing running job + existing_job = order_service.get_running_historical_import_job(vendor_id) + if existing_job: + return { + "success": True, + "message": "Import job already running", + "job_id": existing_job.id, + "estimated_duration_minutes": 5, # Estimate + } + + # Create new job + job = order_service.create_historical_import_job( + vendor_id=vendor_id, + user_id=user_id, + ) + + # Store job ID in onboarding + onboarding.step_order_sync_job_id = job.id + + self.db.flush() + + logger.info(f"Triggered order sync job {job.id} for vendor {vendor_id}") + + return { + "success": True, + "message": "Historical import started", + "job_id": job.id, + "estimated_duration_minutes": 5, # Estimate + } + + def get_order_sync_progress( + self, + vendor_id: int, + job_id: int, + ) -> dict: + """ + Get progress of historical import job. + + Returns current status, progress, and counts. + """ + order_service = LetzshopOrderService(self.db) + job = order_service.get_historical_import_job_by_id(vendor_id, job_id) + + if not job: + return { + "job_id": job_id, + "status": "not_found", + "progress_percentage": 0, + "current_phase": None, + "orders_imported": 0, + "orders_total": None, + "products_imported": 0, + "started_at": None, + "completed_at": None, + "estimated_remaining_seconds": None, + "error_message": "Job not found", + } + + # Calculate progress percentage + progress = 0 + if job.status == "completed": + progress = 100 + elif job.status == "failed": + progress = 0 + elif job.status == "processing": + if job.total_shipments and job.total_shipments > 0: + progress = int((job.processed_shipments or 0) / job.total_shipments * 100) + else: + progress = 50 # Indeterminate + + # Determine current phase + current_phase = None + if job.status == "fetching": + current_phase = "fetching" + elif job.status == "processing": + current_phase = "orders" + elif job.status == "completed": + current_phase = "complete" + + return { + "job_id": job.id, + "status": job.status, + "progress_percentage": progress, + "current_phase": current_phase, + "orders_imported": job.processed_shipments or 0, + "orders_total": job.total_shipments, + "products_imported": 0, # TODO: Track this + "started_at": job.started_at, + "completed_at": job.completed_at, + "estimated_remaining_seconds": None, # TODO: Calculate + "error_message": job.error_message, + } + + def complete_order_sync( + self, + vendor_id: int, + job_id: int, + ) -> dict: + """ + Mark order sync step as complete after job finishes. + + Also marks the entire onboarding as complete. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + # Verify job is complete + order_service = LetzshopOrderService(self.db) + job = order_service.get_historical_import_job_by_id(vendor_id, job_id) + + if not job: + raise OnboardingStepError(f"Job {job_id} not found") + + if job.status not in ("completed", "failed"): + return { + "success": False, + "step_completed": False, + "onboarding_completed": False, + "message": f"Job is still {job.status}, please wait", + "redirect_url": None, + } + + # Mark step complete (even if job failed - they can retry later) + onboarding.mark_step_complete(OnboardingStep.ORDER_SYNC.value) + + # Enable auto-sync now that onboarding is complete + credentials_service = LetzshopCredentialsService(self.db) + credentials = credentials_service.get_credentials(vendor_id) + if credentials: + credentials.auto_sync_enabled = True + + self.db.flush() + + # Get vendor code for redirect URL + vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first() + vendor_code = vendor.vendor_code if vendor else "" + + logger.info(f"Completed onboarding for vendor {vendor_id}") + + return { + "success": True, + "step_completed": True, + "onboarding_completed": True, + "message": "Onboarding complete! Welcome to Wizamart.", + "redirect_url": f"/vendor/{vendor_code}/dashboard", + } + + # ========================================================================= + # Admin Skip + # ========================================================================= + + def skip_onboarding( + self, + vendor_id: int, + admin_user_id: int, + reason: str, + ) -> dict: + """ + Admin-only: Skip onboarding for a vendor. + + Used for support cases where manual setup is needed. + """ + onboarding = self.get_or_create_onboarding(vendor_id) + + onboarding.skipped_by_admin = True + onboarding.skipped_at = datetime.now(UTC) + onboarding.skipped_reason = reason + onboarding.skipped_by_user_id = admin_user_id + onboarding.status = OnboardingStatus.SKIPPED.value + + self.db.flush() + + logger.info( + f"Admin {admin_user_id} skipped onboarding for vendor {vendor_id}: {reason}" + ) + + return { + "success": True, + "message": "Onboarding skipped by admin", + "vendor_id": vendor_id, + "skipped_at": onboarding.skipped_at, + } + + +# Singleton-style convenience instance +def get_onboarding_service(db: Session) -> OnboardingService: + """Get an OnboardingService instance.""" + return OnboardingService(db) diff --git a/app/services/platform_signup_service.py b/app/services/platform_signup_service.py index a8080df4..392d5f3d 100644 --- a/app/services/platform_signup_service.py +++ b/app/services/platform_signup_service.py @@ -23,6 +23,7 @@ from app.exceptions import ( ValidationException, ) from app.services.email_service import EmailService +from app.services.onboarding_service import OnboardingService from app.services.stripe_service import stripe_service from middleware.auth import AuthManager from models.database.company import Company @@ -372,6 +373,10 @@ class PlatformSignupService: ) db.add(vendor_user) + # Create VendorOnboarding record + onboarding_service = OnboardingService(db) + onboarding_service.create_onboarding(vendor.id) + # Create Stripe Customer stripe_customer_id = stripe_service.create_customer( vendor=vendor, @@ -615,11 +620,12 @@ class PlatformSignupService: logger.info(f"Completed signup for vendor {vendor_id}") + # Redirect to onboarding instead of dashboard return SignupCompletionResult( success=True, vendor_code=vendor_code, vendor_id=vendor_id, - redirect_url=f"/vendor/{vendor_code}/dashboard", + redirect_url=f"/vendor/{vendor_code}/onboarding", trial_ends_at=trial_ends_at.isoformat(), ) diff --git a/app/templates/vendor/onboarding.html b/app/templates/vendor/onboarding.html new file mode 100644 index 00000000..7cb94b21 --- /dev/null +++ b/app/templates/vendor/onboarding.html @@ -0,0 +1,380 @@ +{# app/templates/vendor/onboarding.html #} + + + + + + Welcome to Wizamart - Setup Your Account + + + + + + +
+ +
+
+
+
+ W +
+ Wizamart +
+
+ of 4 steps completed +
+
+
+ + +
+
+ +
+
+ + +
+ +
+ + + + +

Loading your setup...

+
+ + +
+
+

+ +
+
+ + +
+ +
+
+

Company Profile

+

+ Let's set up your company information. This will be used for invoices and customer communication. +

+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

Letzshop API Configuration

+

+ Connect your Letzshop marketplace account to sync orders automatically. +

+
+
+
+ + +

+ Get your API key from Letzshop Vendor Portal > Settings > API Access +

+
+
+ +
+ + letzshop.lu/vendors/ + + +
+
+
+ + + + + + Connection successful + + + + + + + +
+
+
+ + +
+
+

Product Import Configuration

+

+ Set up your product CSV feed URLs for each language. At least one URL is required. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+

Letzshop Feed Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+

Historical Order Import

+

+ Import your existing orders from Letzshop to get started with a complete order history. +

+
+
+ +
+
+ + +
+ +
+ + +
+
+
+
+
+
+

+ % Complete +

+

+

+ orders imported +

+
+ + +
+
+ + + +
+

Import Complete!

+

+ orders have been imported. +

+
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + + + + + diff --git a/docs/features/vendor-onboarding.md b/docs/features/vendor-onboarding.md new file mode 100644 index 00000000..039bb885 --- /dev/null +++ b/docs/features/vendor-onboarding.md @@ -0,0 +1,191 @@ +# Vendor Onboarding System + +The vendor onboarding system is a mandatory 4-step wizard that guides new vendors through the initial setup process after signup. Dashboard access is blocked until onboarding is completed. + +## Overview + +The onboarding wizard consists of four sequential steps: + +1. **Company Profile Setup** - Basic company and contact information +2. **Letzshop API Configuration** - Connect to Letzshop marketplace +3. **Product & Order Import Configuration** - Set up CSV feed URLs +4. **Order Sync** - Import historical orders with progress tracking + +## User Flow + +``` +Signup Complete + ↓ +Redirect to /vendor/{code}/onboarding + ↓ +Step 1: Company Profile + ↓ +Step 2: Letzshop API (with connection test) + ↓ +Step 3: Product Import Config + ↓ +Step 4: Order Sync (with progress bar) + ↓ +Onboarding Complete + ↓ +Redirect to Dashboard +``` + +## Key Features + +### Mandatory Completion +- Dashboard and other protected routes redirect to onboarding if not completed +- Admin can skip onboarding for support cases (via admin API) + +### Step Validation +- Steps must be completed in order (no skipping ahead) +- Each step validates required fields before proceeding + +### Progress Persistence +- Onboarding progress is saved in the database +- Users can resume from where they left off +- Page reload doesn't lose progress + +### Connection Testing +- Step 2 includes real-time Letzshop API connection testing +- Shows success/failure status before saving credentials + +### Historical Import +- Step 4 triggers a background job for order import +- Real-time progress bar with polling (2-second intervals) +- Shows order count as import progresses + +## Database Model + +### VendorOnboarding Table + +| Column | Type | Description | +|--------|------|-------------| +| id | Integer | Primary key | +| vendor_id | Integer | Foreign key to vendors (unique) | +| status | String(20) | not_started, in_progress, completed, skipped | +| current_step | String(30) | Current step identifier | +| step_*_completed | Boolean | Completion flag per step | +| step_*_completed_at | DateTime | Completion timestamp per step | +| skipped_by_admin | Boolean | Admin override flag | +| skipped_reason | Text | Reason for skip (admin) | + +### Onboarding Steps Enum + +```python +class OnboardingStep(str, enum.Enum): + COMPANY_PROFILE = "company_profile" + LETZSHOP_API = "letzshop_api" + PRODUCT_IMPORT = "product_import" + ORDER_SYNC = "order_sync" +``` + +## API Endpoints + +All endpoints are under `/api/v1/vendor/onboarding/`: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/status` | Get full onboarding status | +| GET | `/step/company-profile` | Get company profile data | +| POST | `/step/company-profile` | Save company profile | +| POST | `/step/letzshop-api/test` | Test API connection | +| POST | `/step/letzshop-api` | Save API credentials | +| GET | `/step/product-import` | Get import config | +| POST | `/step/product-import` | Save import config | +| POST | `/step/order-sync/trigger` | Start historical import | +| GET | `/step/order-sync/progress/{job_id}` | Get import progress | +| POST | `/step/order-sync/complete` | Complete onboarding | + +## Integration Points + +### Signup Flow + +When a vendor is created during signup, an onboarding record is automatically created: + +```python +# In platform_signup_service.py +onboarding_service = OnboardingService(db) +onboarding_service.create_onboarding(vendor.id) +``` + +### Route Protection + +Protected routes check onboarding status and redirect if not completed: + +```python +# In vendor_pages.py +onboarding_service = OnboardingService(db) +if not onboarding_service.is_completed(current_user.token_vendor_id): + return RedirectResponse(f"/vendor/{vendor_code}/onboarding") +``` + +### Historical Import + +Step 4 uses the existing `LetzshopHistoricalImportJob` infrastructure: + +```python +order_service = LetzshopOrderService(db) +job = order_service.create_historical_import_job(vendor_id, user_id) +``` + +## Frontend Implementation + +### Template + +`app/templates/vendor/onboarding.html`: +- Standalone page (doesn't use vendor base template) +- Progress indicator with step circles +- Animated transitions between steps +- Real-time sync progress bar + +### JavaScript + +`static/vendor/js/onboarding.js`: +- Alpine.js component +- API calls for each step +- Connection test functionality +- Progress polling for order sync + +## Admin Skip Capability + +For support cases, admins can skip onboarding: + +```python +onboarding_service.skip_onboarding( + vendor_id=vendor_id, + admin_user_id=admin_user_id, + reason="Manual setup required for migration" +) +``` + +This sets `skipped_by_admin=True` and allows dashboard access without completing all steps. + +## Files + +| File | Purpose | +|------|---------| +| `models/database/onboarding.py` | Database model and enums | +| `models/schema/onboarding.py` | Pydantic schemas | +| `app/services/onboarding_service.py` | Business logic | +| `app/api/v1/vendor/onboarding.py` | API endpoints | +| `app/routes/vendor_pages.py` | Page routes and redirects | +| `app/templates/vendor/onboarding.html` | Frontend template | +| `static/vendor/js/onboarding.js` | Alpine.js component | +| `alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py` | Migration | + +## Testing + +Run the onboarding tests: + +```bash +pytest tests/integration/api/v1/vendor/test_onboarding.py -v +``` + +## Configuration + +No additional configuration is required. The onboarding system uses existing configurations: + +- Letzshop API: Uses `LetzshopCredentialsService` +- Order Import: Uses `LetzshopOrderService` +- Email: Uses `EmailService` for welcome email (sent after signup) diff --git a/mkdocs.yml b/mkdocs.yml index c6101f48..a782f989 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -200,6 +200,7 @@ nav: - Implementation Guide: features/cms-implementation-guide.md - Platform Homepage: features/platform-homepage.md - Vendor Landing Pages: features/vendor-landing-pages.md + - Vendor Onboarding: features/vendor-onboarding.md - Subscription & Billing: features/subscription-billing.md - Email System: features/email-system.md diff --git a/models/database/__init__.py b/models/database/__init__.py index d959a781..90283132 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -47,6 +47,7 @@ from .marketplace_product import ( ProductType, ) from .marketplace_product_translation import MarketplaceProductTranslation +from .onboarding import OnboardingStatus, OnboardingStep, VendorOnboarding from .order import Order, OrderItem from .order_item_exception import OrderItemException from .product import Product @@ -153,4 +154,8 @@ __all__ = [ "Message", "MessageAttachment", "ParticipantType", + # Onboarding + "OnboardingStatus", + "OnboardingStep", + "VendorOnboarding", ] diff --git a/models/database/onboarding.py b/models/database/onboarding.py new file mode 100644 index 00000000..4b708b01 --- /dev/null +++ b/models/database/onboarding.py @@ -0,0 +1,222 @@ +# models/database/onboarding.py +""" +Vendor onboarding progress tracking. + +Tracks completion status of mandatory onboarding steps for new vendors. +Onboarding must be completed before accessing the vendor dashboard. +""" + +import enum +from datetime import datetime + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, +) +from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy.orm import relationship + +from app.core.database import Base + +from .base import TimestampMixin + + +class OnboardingStep(str, enum.Enum): + """Onboarding step identifiers.""" + + COMPANY_PROFILE = "company_profile" + LETZSHOP_API = "letzshop_api" + PRODUCT_IMPORT = "product_import" + ORDER_SYNC = "order_sync" + + +class OnboardingStatus(str, enum.Enum): + """Overall onboarding status.""" + + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + SKIPPED = "skipped" # For admin override capability + + +# Step order for validation +STEP_ORDER = [ + OnboardingStep.COMPANY_PROFILE, + OnboardingStep.LETZSHOP_API, + OnboardingStep.PRODUCT_IMPORT, + OnboardingStep.ORDER_SYNC, +] + + +class VendorOnboarding(Base, TimestampMixin): + """ + Per-vendor onboarding progress tracking. + + Created automatically when vendor is created during signup. + Blocks dashboard access until status = 'completed' or skipped_by_admin = True. + """ + + __tablename__ = "vendor_onboarding" + + id = Column(Integer, primary_key=True, index=True) + vendor_id = Column( + Integer, + ForeignKey("vendors.id", ondelete="CASCADE"), + unique=True, + nullable=False, + index=True, + ) + + # Overall status + status = Column( + String(20), + default=OnboardingStatus.NOT_STARTED.value, + nullable=False, + index=True, + ) + current_step = Column( + String(30), + default=OnboardingStep.COMPANY_PROFILE.value, + nullable=False, + ) + + # Step 1: Company Profile + step_company_profile_completed = Column(Boolean, default=False, nullable=False) + step_company_profile_completed_at = Column(DateTime(timezone=True), nullable=True) + step_company_profile_data = Column(JSON, nullable=True) # Store what was entered + + # Step 2: Letzshop API Configuration + step_letzshop_api_completed = Column(Boolean, default=False, nullable=False) + step_letzshop_api_completed_at = Column(DateTime(timezone=True), nullable=True) + step_letzshop_api_connection_verified = Column(Boolean, default=False, nullable=False) + + # Step 3: Product & Order Import (CSV feed URL + historical import) + step_product_import_completed = Column(Boolean, default=False, nullable=False) + step_product_import_completed_at = Column(DateTime(timezone=True), nullable=True) + step_product_import_csv_url_set = Column(Boolean, default=False, nullable=False) + + # Step 4: Order Sync + step_order_sync_completed = Column(Boolean, default=False, nullable=False) + step_order_sync_completed_at = Column(DateTime(timezone=True), nullable=True) + step_order_sync_job_id = Column(Integer, nullable=True) # FK to LetzshopHistoricalImportJob + + # Completion tracking + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + + # Admin override (for support cases) + skipped_by_admin = Column(Boolean, default=False, nullable=False) + skipped_at = Column(DateTime(timezone=True), nullable=True) + skipped_reason = Column(Text, nullable=True) + skipped_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + + # Relationships + vendor = relationship("Vendor", back_populates="onboarding") + + __table_args__ = ( + Index("idx_onboarding_vendor_status", "vendor_id", "status"), + {"sqlite_autoincrement": True}, + ) + + def __repr__(self): + return f"" + + @property + def is_completed(self) -> bool: + """Check if onboarding is fully completed or skipped.""" + return ( + self.status == OnboardingStatus.COMPLETED.value or self.skipped_by_admin + ) + + @property + def completion_percentage(self) -> int: + """Calculate completion percentage (0-100).""" + completed_steps = sum( + [ + self.step_company_profile_completed, + self.step_letzshop_api_completed, + self.step_product_import_completed, + self.step_order_sync_completed, + ] + ) + return int((completed_steps / 4) * 100) + + @property + def completed_steps_count(self) -> int: + """Get number of completed steps.""" + return sum( + [ + self.step_company_profile_completed, + self.step_letzshop_api_completed, + self.step_product_import_completed, + self.step_order_sync_completed, + ] + ) + + def is_step_completed(self, step: str) -> bool: + """Check if a specific step is completed.""" + step_mapping = { + OnboardingStep.COMPANY_PROFILE.value: self.step_company_profile_completed, + OnboardingStep.LETZSHOP_API.value: self.step_letzshop_api_completed, + OnboardingStep.PRODUCT_IMPORT.value: self.step_product_import_completed, + OnboardingStep.ORDER_SYNC.value: self.step_order_sync_completed, + } + return step_mapping.get(step, False) + + def can_proceed_to_step(self, step: str) -> bool: + """Check if user can proceed to a specific step (no skipping).""" + target_index = None + for i, s in enumerate(STEP_ORDER): + if s.value == step: + target_index = i + break + + if target_index is None: + return False + + # Check all previous steps are completed + for i in range(target_index): + if not self.is_step_completed(STEP_ORDER[i].value): + return False + + return True + + def get_next_step(self) -> str | None: + """Get the next incomplete step.""" + for step in STEP_ORDER: + if not self.is_step_completed(step.value): + return step.value + return None + + def mark_step_complete(self, step: str, timestamp: datetime | None = None) -> None: + """Mark a step as complete and update current step.""" + if timestamp is None: + timestamp = datetime.utcnow() + + if step == OnboardingStep.COMPANY_PROFILE.value: + self.step_company_profile_completed = True + self.step_company_profile_completed_at = timestamp + elif step == OnboardingStep.LETZSHOP_API.value: + self.step_letzshop_api_completed = True + self.step_letzshop_api_completed_at = timestamp + elif step == OnboardingStep.PRODUCT_IMPORT.value: + self.step_product_import_completed = True + self.step_product_import_completed_at = timestamp + elif step == OnboardingStep.ORDER_SYNC.value: + self.step_order_sync_completed = True + self.step_order_sync_completed_at = timestamp + + # Update current step to next incomplete step + next_step = self.get_next_step() + if next_step: + self.current_step = next_step + else: + # All steps complete + self.status = OnboardingStatus.COMPLETED.value + self.completed_at = timestamp diff --git a/models/database/vendor.py b/models/database/vendor.py index b4b9c3e0..0ffc7fd2 100644 --- a/models/database/vendor.py +++ b/models/database/vendor.py @@ -211,6 +211,14 @@ class Vendor(Base, TimestampMixin): "ContentPage", back_populates="vendor", cascade="all, delete-orphan" ) # Relationship with ContentPage model for vendor-specific content pages + # Onboarding progress (one-to-one) + onboarding = relationship( + "VendorOnboarding", + back_populates="vendor", + uselist=False, + cascade="all, delete-orphan", + ) + def __repr__(self): """String representation of the Vendor object.""" return f"" diff --git a/models/schema/__init__.py b/models/schema/__init__.py index 803e8e05..96e127a3 100644 --- a/models/schema/__init__.py +++ b/models/schema/__init__.py @@ -10,6 +10,7 @@ from . import ( marketplace_import_job, marketplace_product, message, + onboarding, stats, vendor, ) @@ -24,6 +25,7 @@ __all__ = [ "marketplace_product", "message", "inventory", + "onboarding", "vendor", "marketplace_import_job", "stats", diff --git a/models/schema/onboarding.py b/models/schema/onboarding.py new file mode 100644 index 00000000..2baad5c0 --- /dev/null +++ b/models/schema/onboarding.py @@ -0,0 +1,291 @@ +# models/schema/onboarding.py +""" +Pydantic schemas for Vendor Onboarding operations. + +Schemas include: +- OnboardingStatusResponse: Current onboarding status with all step states +- CompanyProfileRequest/Response: Step 1 - Company profile data +- LetzshopApiConfigRequest/Response: Step 2 - API configuration +- ProductImportConfigRequest/Response: Step 3 - CSV URL configuration +- OrderSyncTriggerResponse: Step 4 - Job trigger response +- OrderSyncProgressResponse: Step 4 - Progress polling response +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +# ============================================================================= +# STEP STATUS MODELS +# ============================================================================= + + +class StepStatus(BaseModel): + """Status for a single onboarding step.""" + + completed: bool = False + completed_at: datetime | None = None + + +class CompanyProfileStepStatus(StepStatus): + """Step 1 status with saved data.""" + + data: dict | None = None + + +class LetzshopApiStepStatus(StepStatus): + """Step 2 status with connection verification.""" + + connection_verified: bool = False + + +class ProductImportStepStatus(StepStatus): + """Step 3 status with CSV URL flag.""" + + csv_url_set: bool = False + + +class OrderSyncStepStatus(StepStatus): + """Step 4 status with job tracking.""" + + job_id: int | None = None + + +# ============================================================================= +# ONBOARDING STATUS RESPONSE +# ============================================================================= + + +class OnboardingStatusResponse(BaseModel): + """Full onboarding status with all step information.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + vendor_id: int + status: str # not_started, in_progress, completed, skipped + current_step: str # company_profile, letzshop_api, product_import, order_sync + + # Step statuses + company_profile: CompanyProfileStepStatus + letzshop_api: LetzshopApiStepStatus + product_import: ProductImportStepStatus + order_sync: OrderSyncStepStatus + + # Progress tracking + completion_percentage: int + completed_steps_count: int + total_steps: int = 4 + + # Completion info + is_completed: bool + started_at: datetime | None = None + completed_at: datetime | None = None + + # Admin override info + skipped_by_admin: bool = False + skipped_at: datetime | None = None + skipped_reason: str | None = None + + +# ============================================================================= +# STEP 1: COMPANY PROFILE +# ============================================================================= + + +class CompanyProfileRequest(BaseModel): + """Request to save company profile during onboarding Step 1.""" + + # Company name is already set during signup, but can be updated + company_name: str | None = Field(None, min_length=2, max_length=255) + + # Vendor/brand name + brand_name: str | None = Field(None, min_length=2, max_length=255) + description: str | None = Field(None, max_length=2000) + + # Contact information + contact_email: str | None = Field(None, max_length=255) + contact_phone: str | None = Field(None, max_length=50) + website: str | None = Field(None, max_length=255) + business_address: str | None = Field(None, max_length=500) + tax_number: str | None = Field(None, max_length=100) + + # Language preferences + default_language: str = Field("fr", pattern="^(en|fr|de|lb)$") + dashboard_language: str = Field("fr", pattern="^(en|fr|de|lb)$") + + +class CompanyProfileResponse(BaseModel): + """Response after saving company profile.""" + + success: bool + step_completed: bool + next_step: str | None = None + message: str | None = None + + +# ============================================================================= +# STEP 2: LETZSHOP API CONFIGURATION +# ============================================================================= + + +class LetzshopApiConfigRequest(BaseModel): + """Request to configure Letzshop API credentials.""" + + api_key: str = Field(..., min_length=10, description="Letzshop API key") + shop_slug: str = Field( + ..., + min_length=2, + max_length=100, + description="Letzshop shop URL slug (e.g., 'my-shop')", + ) + vendor_id: str | None = Field( + None, + max_length=100, + description="Letzshop vendor ID (optional, auto-detected if not provided)", + ) + + +class LetzshopApiTestRequest(BaseModel): + """Request to test Letzshop API connection.""" + + api_key: str = Field(..., min_length=10) + shop_slug: str = Field(..., min_length=2, max_length=100) + + +class LetzshopApiTestResponse(BaseModel): + """Response from Letzshop API connection test.""" + + success: bool + message: str + vendor_name: str | None = None + vendor_id: str | None = None + shop_slug: str | None = None + + +class LetzshopApiConfigResponse(BaseModel): + """Response after saving Letzshop API configuration.""" + + success: bool + step_completed: bool + next_step: str | None = None + message: str | None = None + connection_verified: bool = False + + +# ============================================================================= +# STEP 3: PRODUCT & ORDER IMPORT CONFIGURATION +# ============================================================================= + + +class ProductImportConfigRequest(BaseModel): + """Request to configure product import settings.""" + + # CSV feed URLs for each language + csv_url_fr: str | None = Field(None, max_length=500) + csv_url_en: str | None = Field(None, max_length=500) + csv_url_de: str | None = Field(None, max_length=500) + + # Letzshop feed settings + default_tax_rate: int = Field( + 17, + ge=0, + le=17, + description="Default VAT rate: 0, 3, 8, 14, or 17", + ) + delivery_method: str = Field( + "package_delivery", + description="Delivery method: nationwide, package_delivery, self_collect", + ) + preorder_days: int = Field(1, ge=0, le=30) + + +class ProductImportConfigResponse(BaseModel): + """Response after saving product import configuration.""" + + success: bool + step_completed: bool + next_step: str | None = None + message: str | None = None + csv_urls_configured: int = 0 + + +# ============================================================================= +# STEP 4: ORDER SYNC +# ============================================================================= + + +class OrderSyncTriggerRequest(BaseModel): + """Request to trigger historical order import.""" + + # How far back to import orders (days) + days_back: int = Field(90, ge=1, le=365, description="Days of order history to import") + include_products: bool = Field(True, description="Also import products from Letzshop") + + +class OrderSyncTriggerResponse(BaseModel): + """Response after triggering order sync job.""" + + success: bool + message: str + job_id: int | None = None + estimated_duration_minutes: int | None = None + + +class OrderSyncProgressResponse(BaseModel): + """Response for order sync progress polling.""" + + job_id: int + status: str # pending, running, completed, failed + progress_percentage: int = 0 + current_phase: str | None = None # products, orders, finalizing + + # Counts + orders_imported: int = 0 + orders_total: int | None = None + products_imported: int = 0 + + # Timing + started_at: datetime | None = None + completed_at: datetime | None = None + estimated_remaining_seconds: int | None = None + + # Error info if failed + error_message: str | None = None + + +class OrderSyncCompleteRequest(BaseModel): + """Request to mark order sync as complete.""" + + job_id: int + + +class OrderSyncCompleteResponse(BaseModel): + """Response after completing order sync step.""" + + success: bool + step_completed: bool + onboarding_completed: bool = False + message: str | None = None + redirect_url: str | None = None + + +# ============================================================================= +# ADMIN SKIP +# ============================================================================= + + +class OnboardingSkipRequest(BaseModel): + """Request to skip onboarding (admin only).""" + + reason: str = Field(..., min_length=10, max_length=500) + + +class OnboardingSkipResponse(BaseModel): + """Response after skipping onboarding.""" + + success: bool + message: str + vendor_id: int + skipped_at: datetime diff --git a/static/vendor/js/onboarding.js b/static/vendor/js/onboarding.js new file mode 100644 index 00000000..2a5073d2 --- /dev/null +++ b/static/vendor/js/onboarding.js @@ -0,0 +1,346 @@ +// static/vendor/js/onboarding.js +/** + * Vendor Onboarding Wizard + * + * Handles the 4-step mandatory onboarding flow: + * 1. Company Profile Setup + * 2. Letzshop API Configuration + * 3. Product & Order Import Configuration + * 4. Order Sync (historical import) + */ + +function vendorOnboarding() { + return { + // State + loading: true, + saving: false, + testing: false, + error: null, + + // Steps configuration + steps: [ + { id: 'company_profile', title: 'Company Profile' }, + { id: 'letzshop_api', title: 'Letzshop API' }, + { id: 'product_import', title: 'Product Import' }, + { id: 'order_sync', title: 'Order Sync' }, + ], + + // Current state + currentStep: 'company_profile', + completedSteps: 0, + status: null, + + // Form data + formData: { + // Step 1: Company Profile + company_name: '', + brand_name: '', + description: '', + contact_email: '', + contact_phone: '', + website: '', + business_address: '', + tax_number: '', + default_language: 'fr', + dashboard_language: 'fr', + + // Step 2: Letzshop API + api_key: '', + shop_slug: '', + + // Step 3: Product Import + csv_url_fr: '', + csv_url_en: '', + csv_url_de: '', + default_tax_rate: 17, + delivery_method: 'package_delivery', + preorder_days: 1, + + // Step 4: Order Sync + days_back: 90, + }, + + // Letzshop connection test state + connectionStatus: null, // null, 'success', 'failed' + connectionError: null, + + // Order sync state + syncJobId: null, + syncProgress: 0, + syncPhase: '', + ordersImported: 0, + syncComplete: false, + syncPollInterval: null, + + // Computed + get currentStepIndex() { + return this.steps.findIndex(s => s.id === this.currentStep); + }, + + // Initialize + async init() { + await this.loadStatus(); + }, + + // Load current onboarding status + async loadStatus() { + this.loading = true; + this.error = null; + + try { + const response = await window.apiClient.get('/api/v1/vendor/onboarding/status'); + this.status = response; + this.currentStep = response.current_step; + this.completedSteps = response.completed_steps_count; + + // Pre-populate form data from status if available + if (response.company_profile?.data) { + Object.assign(this.formData, response.company_profile.data); + } + + // Check if we were in the middle of an order sync + if (response.order_sync?.job_id && this.currentStep === 'order_sync') { + this.syncJobId = response.order_sync.job_id; + this.startSyncPolling(); + } + + // Load step-specific data + await this.loadStepData(); + } catch (err) { + console.error('Failed to load onboarding status:', err); + this.error = err.message || 'Failed to load onboarding status'; + } finally { + this.loading = false; + } + }, + + // Load data for current step + async loadStepData() { + try { + if (this.currentStep === 'company_profile') { + const data = await window.apiClient.get('/api/v1/vendor/onboarding/step/company-profile'); + if (data) { + Object.assign(this.formData, data); + } + } else if (this.currentStep === 'product_import') { + const data = await window.apiClient.get('/api/v1/vendor/onboarding/step/product-import'); + if (data) { + Object.assign(this.formData, { + csv_url_fr: data.csv_url_fr || '', + csv_url_en: data.csv_url_en || '', + csv_url_de: data.csv_url_de || '', + default_tax_rate: data.default_tax_rate || 17, + delivery_method: data.delivery_method || 'package_delivery', + preorder_days: data.preorder_days || 1, + }); + } + } + } catch (err) { + console.warn('Failed to load step data:', err); + } + }, + + // Check if a step is completed + isStepCompleted(stepId) { + if (!this.status) return false; + const stepData = this.status[stepId]; + return stepData?.completed === true; + }, + + // Go to previous step + goToPreviousStep() { + const prevIndex = this.currentStepIndex - 1; + if (prevIndex >= 0) { + this.currentStep = this.steps[prevIndex].id; + this.loadStepData(); + } + }, + + // Test Letzshop API connection + async testLetzshopApi() { + this.testing = true; + this.connectionStatus = null; + this.connectionError = null; + + try { + const response = await window.apiClient.post('/api/v1/vendor/onboarding/step/letzshop-api/test', { + api_key: this.formData.api_key, + shop_slug: this.formData.shop_slug, + }); + + if (response.success) { + this.connectionStatus = 'success'; + } else { + this.connectionStatus = 'failed'; + this.connectionError = response.message; + } + } catch (err) { + this.connectionStatus = 'failed'; + this.connectionError = err.message || 'Connection test failed'; + } finally { + this.testing = false; + } + }, + + // Start order sync + async startOrderSync() { + this.saving = true; + + try { + const response = await window.apiClient.post('/api/v1/vendor/onboarding/step/order-sync/trigger', { + days_back: parseInt(this.formData.days_back), + include_products: true, + }); + + if (response.success && response.job_id) { + this.syncJobId = response.job_id; + this.startSyncPolling(); + } else { + throw new Error(response.message || 'Failed to start import'); + } + } catch (err) { + console.error('Failed to start order sync:', err); + this.error = err.message || 'Failed to start import'; + } finally { + this.saving = false; + } + }, + + // Start polling for sync progress + startSyncPolling() { + this.syncPollInterval = setInterval(async () => { + await this.pollSyncProgress(); + }, 2000); + }, + + // Poll sync progress + async pollSyncProgress() { + try { + const response = await window.apiClient.get( + `/api/v1/vendor/onboarding/step/order-sync/progress/${this.syncJobId}` + ); + + this.syncProgress = response.progress_percentage || 0; + this.syncPhase = this.formatPhase(response.current_phase); + this.ordersImported = response.orders_imported || 0; + + if (response.status === 'completed' || response.status === 'failed') { + this.stopSyncPolling(); + this.syncComplete = true; + this.syncProgress = response.status === 'completed' ? 100 : this.syncProgress; + } + } catch (err) { + console.error('Failed to poll sync progress:', err); + } + }, + + // Stop sync polling + stopSyncPolling() { + if (this.syncPollInterval) { + clearInterval(this.syncPollInterval); + this.syncPollInterval = null; + } + }, + + // Format phase for display + formatPhase(phase) { + const phases = { + fetching: 'Fetching orders from Letzshop...', + orders: 'Processing orders...', + products: 'Importing products...', + finalizing: 'Finalizing import...', + complete: 'Import complete!', + }; + return phases[phase] || 'Processing...'; + }, + + // Save current step and continue + async saveAndContinue() { + this.saving = true; + this.error = null; + + try { + let endpoint = ''; + let payload = {}; + + switch (this.currentStep) { + case 'company_profile': + endpoint = '/api/v1/vendor/onboarding/step/company-profile'; + payload = { + company_name: this.formData.company_name, + brand_name: this.formData.brand_name, + description: this.formData.description, + contact_email: this.formData.contact_email, + contact_phone: this.formData.contact_phone, + website: this.formData.website, + business_address: this.formData.business_address, + tax_number: this.formData.tax_number, + default_language: this.formData.default_language, + dashboard_language: this.formData.dashboard_language, + }; + break; + + case 'letzshop_api': + endpoint = '/api/v1/vendor/onboarding/step/letzshop-api'; + payload = { + api_key: this.formData.api_key, + shop_slug: this.formData.shop_slug, + }; + break; + + case 'product_import': + endpoint = '/api/v1/vendor/onboarding/step/product-import'; + payload = { + csv_url_fr: this.formData.csv_url_fr || null, + csv_url_en: this.formData.csv_url_en || null, + csv_url_de: this.formData.csv_url_de || null, + default_tax_rate: parseInt(this.formData.default_tax_rate), + delivery_method: this.formData.delivery_method, + preorder_days: parseInt(this.formData.preorder_days), + }; + break; + + case 'order_sync': + // Complete onboarding + endpoint = '/api/v1/vendor/onboarding/step/order-sync/complete'; + payload = { + job_id: this.syncJobId, + }; + break; + } + + const response = await window.apiClient.post(endpoint, payload); + + if (!response.success) { + throw new Error(response.message || 'Save failed'); + } + + // Handle completion + if (response.onboarding_completed || response.redirect_url) { + // Redirect to dashboard + window.location.href = response.redirect_url || window.location.pathname.replace('/onboarding', '/dashboard'); + return; + } + + // Move to next step + if (response.next_step) { + this.currentStep = response.next_step; + this.completedSteps++; + await this.loadStepData(); + } + + } catch (err) { + console.error('Failed to save step:', err); + this.error = err.message || 'Failed to save. Please try again.'; + } finally { + this.saving = false; + } + }, + + // Dark mode + get dark() { + return localStorage.getItem('dark') === 'true' || + (!localStorage.getItem('dark') && window.matchMedia('(prefers-color-scheme: dark)').matches); + }, + }; +}