Files
orion/docs/backend/admin-feature-integration.md
Samir Boulahtit 0d7915c275 docs: update authentication dependency names across documentation
Updated all documentation to use correct authentication dependency names:
- HTML pages: get_current_admin_from_cookie_or_header, get_current_vendor_from_cookie_or_header, get_current_customer_from_cookie_or_header
- API endpoints: get_current_admin_api, get_current_vendor_api, get_current_customer_api

Changes:
- Updated authentication flow diagrams with correct dependency names for admin and vendor flows
- Added comprehensive customer/shop authentication flow documentation
- Updated cookie isolation architecture to show all three contexts (admin, vendor, shop)
- Expanded security boundary enforcement diagram to include shop routes
- Added customer cross-context prevention examples
- Updated all code examples in frontend and backend documentation
- Fixed import statements to use app.api.deps instead of app.core.auth

Files updated:
- docs/api/authentication-flow-diagrams.md (added customer flows)
- docs/frontend/admin/page-templates.md
- docs/frontend/admin/architecture.md
- docs/frontend/shared/ui-components.md
- docs/frontend/shared/sidebar.md
- docs/development/exception-handling.md
- docs/architecture/diagrams/vendor-domain-diagrams.md
- docs/backend/admin-integration-guide.md
- docs/backend/admin-feature-integration.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 18:21:23 +01:00

52 KiB

Admin Feature Integration Guide

Overview

This guide provides a step-by-step process for adding new admin features to the multi-tenant e-commerce platform. It ensures consistency with the established architecture patterns, naming conventions, and best practices.

Table of Contents

  1. Planning Phase
  2. Database Layer
  3. Schema Layer (Pydantic)
  4. Exception Layer
  5. Service Layer
  6. API Layer (Endpoints)
  7. Frontend Layer
  8. Testing
  9. Documentation
  10. Deployment Checklist

Planning Phase

1. Define the Feature

Questions to Answer:

  • What is the feature name? (Use singular for entity, plural for collections)
  • What business problem does it solve?
  • What are the CRUD operations needed?
  • What relationships exist with other entities?
  • What validation rules apply?
  • What permissions are required?

Example: Adding "Vendor Domains" Feature

Feature Name: Vendor Domains
Purpose: Allow vendors to use custom domains
Operations: Create, Read, Update, Delete, Verify
Relationships: Belongs to Vendor
Validation: Domain format, uniqueness, DNS verification
Permissions: Admin only

2. Review Naming Conventions

Consult: 6_complete_naming_convention.md

Key Rules:

  • Collections/Endpoints: PLURAL (vendors.py, domains.py)
  • Entities/Models: SINGULAR (vendor.py, domain.py)
  • Services: SINGULAR + "service" (vendor_service.py, domain_service.py)
  • Exceptions: SINGULAR domain name (vendor.py, domain.py)

3. Identify Dependencies

Check existing modules:

  • Related database models
  • Existing services to reuse
  • Exception types needed
  • Authentication requirements

Database Layer

Step 1: Create Database Model

Location: models/database/{entity}.py (SINGULAR)

File Naming: Use singular entity name

  • models/database/vendor_domain.py
  • models/database/vendor_domains.py

Template:

# models/database/vendor_domain.py
"""
VendorDomain database model.

This model represents custom domains for vendors.
"""

from datetime import datetime, timezone
from sqlalchemy import (
    Column, Integer, String, Boolean, DateTime,
    ForeignKey, UniqueConstraint, Index
)
from sqlalchemy.orm import relationship

from app.core.database import Base
from models.database.base import TimestampMixin


class VendorDomain(Base, TimestampMixin):
    """
    Custom domain mapping for vendors.
    
    Allows vendors to use their own domains (e.g., myshop.com)
    instead of subdomains (vendor1.platform.com).
    """
    __tablename__ = "vendor_domains"  # PLURAL table name

    # Primary Key
    id = Column(Integer, primary_key=True, index=True)
    
    # Foreign Keys
    vendor_id = Column(
        Integer, 
        ForeignKey("vendors.id", ondelete="CASCADE"), 
        nullable=False,
        index=True
    )

    # Domain Configuration
    domain = Column(String(255), nullable=False, unique=True, index=True)
    is_primary = Column(Boolean, default=False, nullable=False)
    is_active = Column(Boolean, default=True, nullable=False)
    
    # Verification
    is_verified = Column(Boolean, default=False, nullable=False)
    verification_token = Column(String(100), unique=True, nullable=True)
    verified_at = Column(DateTime(timezone=True), nullable=True)
    
    # SSL Status
    ssl_status = Column(String(50), default="pending")
    ssl_verified_at = Column(DateTime(timezone=True), nullable=True)

    # Relationships
    vendor = relationship("Vendor", back_populates="domains")

    # Constraints and Indexes
    __table_args__ = (
        UniqueConstraint('vendor_id', 'domain', name='uq_vendor_domain'),
        Index('idx_domain_active', 'domain', 'is_active'),
        Index('idx_vendor_primary', 'vendor_id', 'is_primary'),
    )

    def __repr__(self):
        return f"<VendorDomain(id={self.id}, domain='{self.domain}', vendor_id={self.vendor_id})>"

    # Helper Methods
    @classmethod
    def normalize_domain(cls, domain: str) -> str:
        """Normalize domain for consistent storage."""
        domain = domain.replace("https://", "").replace("http://", "")
        domain = domain.rstrip("/")
        domain = domain.lower()
        return domain

    @property
    def full_url(self):
        """Return full URL with https."""
        return f"https://{self.domain}"

Key Points:

  • Use TimestampMixin for created_at/updated_at
  • Add proper indexes for query performance
  • Include foreign key constraints with ondelete behavior
  • Use singular class name, plural table name
  • Add docstrings for class and complex methods
  • Include __repr__ for debugging

Location: Update the parent model (e.g., models/database/vendor.py)

# models/database/vendor.py

class Vendor(Base, TimestampMixin):
    __tablename__ = "vendors"
    
    # ... existing fields ...
    
    # Add relationship
    domains = relationship(
        "VendorDomain",
        back_populates="vendor",
        cascade="all, delete-orphan",
        order_by="VendorDomain.is_primary.desc()"
    )
    
    # Helper method
    @property
    def primary_domain(self):
        """Get the primary custom domain for this vendor."""
        for domain in self.domains:
            if domain.is_primary and domain.is_active:
                return domain.domain
        return None

Step 3: Create Database Migration

# Create migration
alembic revision --autogenerate -m "Add vendor_domains table"

# Review the generated migration file
# Edit if needed for data migrations or complex changes

# Apply migration
alembic upgrade head

Migration Checklist:

  • Table created with correct name
  • All columns present with correct types
  • Indexes created
  • Foreign keys configured
  • Unique constraints added
  • Default values set

Schema Layer (Pydantic)

Step 1: Create Pydantic Schemas

Location: models/schema/{entity}.py (SINGULAR)

File Naming: Use singular entity name

  • models/schema/vendor_domain.py
  • models/schema/vendor_domains.py

Template:

# models/schema/vendor_domain.py
"""
Pydantic schemas for VendorDomain operations.

Schemas include:
- VendorDomainCreate: For adding custom domains
- VendorDomainUpdate: For updating domain settings
- VendorDomainResponse: Standard domain response
- VendorDomainListResponse: Paginated domain list
"""

import re
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, ConfigDict, Field, field_validator


# ============================================================================
# Request Schemas (Input)
# ============================================================================

class VendorDomainCreate(BaseModel):
    """Schema for adding a custom domain to vendor."""

    domain: str = Field(
        ...,
        description="Custom domain (e.g., myshop.com)",
        min_length=3,
        max_length=255,
        examples=["myshop.com", "shop.mybrand.com"]
    )
    is_primary: bool = Field(
        default=False,
        description="Set as primary domain for the vendor"
    )

    @field_validator('domain')
    @classmethod
    def validate_domain(cls, v: str) -> str:
        """Validate and normalize domain."""
        # Remove protocol if present
        domain = v.replace("https://", "").replace("http://", "")
        
        # Remove trailing slash
        domain = domain.rstrip("/")
        
        # Convert to lowercase
        domain = domain.lower().strip()

        # Basic validation
        if not domain or '/' in domain:
            raise ValueError("Invalid domain format")

        if '.' not in domain:
            raise ValueError("Domain must have at least one dot")

        # Check for reserved subdomains
        reserved = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp']
        first_part = domain.split('.')[0]
        if first_part in reserved:
            raise ValueError(f"Domain cannot start with reserved subdomain: {first_part}")

        # Validate domain format (basic regex)
        pattern = r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$'
        if not re.match(pattern, domain):
            raise ValueError("Invalid domain format")

        return domain


class VendorDomainUpdate(BaseModel):
    """Schema for updating vendor domain settings."""

    is_primary: Optional[bool] = Field(None, description="Set as primary domain")
    is_active: Optional[bool] = Field(None, description="Activate or deactivate domain")

    model_config = ConfigDict(from_attributes=True)


# ============================================================================
# Response Schemas (Output)
# ============================================================================

class VendorDomainResponse(BaseModel):
    """Standard schema for vendor domain response."""
    model_config = ConfigDict(from_attributes=True)

    id: int
    vendor_id: int
    domain: str
    is_primary: bool
    is_active: bool
    is_verified: bool
    ssl_status: str
    verification_token: Optional[str] = None
    verified_at: Optional[datetime] = None
    ssl_verified_at: Optional[datetime] = None
    created_at: datetime
    updated_at: datetime


class VendorDomainListResponse(BaseModel):
    """Schema for paginated vendor domain list."""
    
    domains: List[VendorDomainResponse]
    total: int


# ============================================================================
# Specialized Schemas
# ============================================================================

class DomainVerificationInstructions(BaseModel):
    """DNS verification instructions for domain ownership."""

    domain: str
    verification_token: str
    instructions: Dict[str, str]
    txt_record: Dict[str, str]
    common_registrars: Dict[str, str]


class DomainVerificationResponse(BaseModel):
    """Response after domain verification."""

    message: str
    domain: str
    verified_at: datetime
    is_verified: bool

Key Points:

  • Separate request schemas (Create, Update) from response schemas
  • Use Field() for descriptions and examples
  • Add validators with @field_validator
  • Use ConfigDict(from_attributes=True) for ORM compatibility
  • Document all fields with descriptions
  • Use appropriate types (Optional, List, Dict)

Exception Layer

Step 1: Create Custom Exceptions

Location: app/exceptions/{domain}.py (SINGULAR)

File Naming: Use singular domain name

  • app/exceptions/vendor_domain.py
  • app/exceptions/vendor_domains.py

Template:

# app/exceptions/vendor_domain.py
"""
Vendor domain management specific exceptions.

All exceptions follow the pattern:
- Inherit from appropriate base exception
- Include relevant context in details dict
- Use proper HTTP status codes
- Provide clear, actionable error messages
"""

from typing import Any, Dict, Optional
from .base import (
    ResourceNotFoundException,
    ConflictException,
    ValidationException,
    BusinessLogicException,
    ExternalServiceException
)


# ============================================================================
# Resource Not Found (404)
# ============================================================================

class VendorDomainNotFoundException(ResourceNotFoundException):
    """Raised when a vendor domain is not found."""

    def __init__(self, domain_identifier: str, identifier_type: str = "ID"):
        if identifier_type.lower() == "domain":
            message = f"Domain '{domain_identifier}' not found"
        else:
            message = f"Domain with ID '{domain_identifier}' not found"

        super().__init__(
            resource_type="VendorDomain",
            identifier=domain_identifier,
            message=message,
            error_code="VENDOR_DOMAIN_NOT_FOUND",
        )


# ============================================================================
# Conflicts (409)
# ============================================================================

class VendorDomainAlreadyExistsException(ConflictException):
    """Raised when trying to add a domain that already exists."""

    def __init__(self, domain: str, existing_vendor_id: Optional[int] = None):
        details = {"domain": domain}
        if existing_vendor_id:
            details["existing_vendor_id"] = existing_vendor_id

        super().__init__(
            message=f"Domain '{domain}' is already registered",
            error_code="VENDOR_DOMAIN_ALREADY_EXISTS",
            details=details,
        )


# ============================================================================
# Validation Errors (422)
# ============================================================================

class InvalidDomainFormatException(ValidationException):
    """Raised when domain format is invalid."""

    def __init__(self, domain: str, reason: str = "Invalid domain format"):
        super().__init__(
            message=f"{reason}: {domain}",
            field="domain",
            details={"domain": domain, "reason": reason},
        )
        self.error_code = "INVALID_DOMAIN_FORMAT"


class ReservedDomainException(ValidationException):
    """Raised when trying to use a reserved domain."""

    def __init__(self, domain: str, reserved_part: str):
        super().__init__(
            message=f"Domain cannot use reserved subdomain: {reserved_part}",
            field="domain",
            details={"domain": domain, "reserved_part": reserved_part},
        )
        self.error_code = "RESERVED_DOMAIN"


# ============================================================================
# Business Logic Errors (400)
# ============================================================================

class DomainNotVerifiedException(BusinessLogicException):
    """Raised when trying to activate an unverified domain."""

    def __init__(self, domain_id: int, domain: str):
        super().__init__(
            message=f"Domain '{domain}' must be verified before activation",
            error_code="DOMAIN_NOT_VERIFIED",
            details={"domain_id": domain_id, "domain": domain},
        )


class DomainVerificationFailedException(BusinessLogicException):
    """Raised when domain verification fails."""

    def __init__(self, domain: str, reason: str):
        super().__init__(
            message=f"Domain verification failed for '{domain}': {reason}",
            error_code="DOMAIN_VERIFICATION_FAILED",
            details={"domain": domain, "reason": reason},
        )


class MaxDomainsReachedException(BusinessLogicException):
    """Raised when vendor tries to add more domains than allowed."""

    def __init__(self, vendor_id: int, max_domains: int):
        super().__init__(
            message=f"Maximum number of domains reached ({max_domains})",
            error_code="MAX_DOMAINS_REACHED",
            details={"vendor_id": vendor_id, "max_domains": max_domains},
        )


# ============================================================================
# External Service Errors (502)
# ============================================================================

class DNSVerificationException(ExternalServiceException):
    """Raised when DNS verification service fails."""

    def __init__(self, domain: str, reason: str):
        super().__init__(
            service_name="DNS",
            message=f"DNS verification failed for '{domain}': {reason}",
            error_code="DNS_VERIFICATION_ERROR",
            details={"domain": domain, "reason": reason},
        )

Exception Categories:

HTTP Status Base Exception Use Case
400 BusinessLogicException Business rule violations
401 AuthenticationException Authentication failures
403 AuthorizationException Permission denied
404 ResourceNotFoundException Resource not found
409 ConflictException Resource conflicts
422 ValidationException Input validation errors
429 RateLimitException Rate limiting
500 WizamartException Generic errors
502 ExternalServiceException Third-party failures

Step 2: Update Exception Exports

Location: app/exceptions/__init__.py

# app/exceptions/__init__.py

# ... existing imports ...

# Vendor domain exceptions
from .vendor_domain import (
    VendorDomainNotFoundException,
    VendorDomainAlreadyExistsException,
    InvalidDomainFormatException,
    ReservedDomainException,
    DomainNotVerifiedException,
    DomainVerificationFailedException,
    MaxDomainsReachedException,
    DNSVerificationException,
)

__all__ = [
    # ... existing exports ...
    
    # Vendor Domain
    "VendorDomainNotFoundException",
    "VendorDomainAlreadyExistsException",
    "InvalidDomainFormatException",
    "ReservedDomainException",
    "DomainNotVerifiedException",
    "DomainVerificationFailedException",
    "MaxDomainsReachedException",
    "DNSVerificationException",
]

Service Layer

Step 1: Create Service Class

Location: app/services/{entity}_service.py (SINGULAR + service)

File Naming: Use singular entity name + "_service"

  • app/services/vendor_domain_service.py
  • app/services/vendor_domains_service.py

Template:

# app/services/vendor_domain_service.py
"""
Vendor domain service for managing custom domain operations.

This module provides classes and functions for:
- Adding and removing custom domains
- Domain verification via DNS
- Domain activation and deactivation
- Setting primary domains
- Domain validation and normalization
"""

import logging
import secrets
from typing import List, Tuple, Optional
from datetime import datetime, timezone

from sqlalchemy.orm import Session

from app.exceptions import (
    VendorNotFoundException,
    VendorDomainNotFoundException,
    VendorDomainAlreadyExistsException,
    InvalidDomainFormatException,
    DomainNotVerifiedException,
    DomainVerificationFailedException,
    MaxDomainsReachedException,
    ValidationException,
)
from models.schema.vendor_domain import VendorDomainCreate, VendorDomainUpdate
from models.database.vendor import Vendor
from models.database.vendor_domain import VendorDomain

logger = logging.getLogger(__name__)


class VendorDomainService:
    """Service class for vendor domain operations."""

    def __init__(self):
        """Initialize service with configuration."""
        self.max_domains_per_vendor = 10
        self.reserved_subdomains = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp']

    def add_domain(
        self,
        db: Session,
        vendor_id: int,
        domain_data: VendorDomainCreate
    ) -> VendorDomain:
        """
        Add a custom domain to vendor.

        Args:
            db: Database session
            vendor_id: Vendor ID to add domain to
            domain_data: Domain creation data

        Returns:
            Created VendorDomain object

        Raises:
            VendorNotFoundException: If vendor not found
            VendorDomainAlreadyExistsException: If domain already registered
            MaxDomainsReachedException: If vendor has reached max domains
            InvalidDomainFormatException: If domain format is invalid
        """
        try:
            # 1. Verify vendor exists
            vendor = self._get_vendor_by_id_or_raise(db, vendor_id)

            # 2. Check domain limit
            self._check_domain_limit(db, vendor_id)

            # 3. Normalize domain
            normalized_domain = VendorDomain.normalize_domain(domain_data.domain)

            # 4. Validate domain format
            self._validate_domain_format(normalized_domain)

            # 5. Check if domain already exists
            if self._domain_exists(db, normalized_domain):
                existing = db.query(VendorDomain).filter(
                    VendorDomain.domain == normalized_domain
                ).first()
                raise VendorDomainAlreadyExistsException(
                    normalized_domain,
                    existing.vendor_id if existing else None
                )

            # 6. If setting as primary, unset other primary domains
            if domain_data.is_primary:
                self._unset_primary_domains(db, vendor_id)

            # 7. Create domain record
            new_domain = VendorDomain(
                vendor_id=vendor_id,
                domain=normalized_domain,
                is_primary=domain_data.is_primary,
                verification_token=secrets.token_urlsafe(32),
                is_verified=False,
                is_active=False,
                ssl_status="pending"
            )

            db.add(new_domain)
            db.commit()
            db.refresh(new_domain)

            logger.info(f"Domain {normalized_domain} added to vendor {vendor_id}")
            return new_domain

        except (
            VendorNotFoundException,
            VendorDomainAlreadyExistsException,
            MaxDomainsReachedException,
            InvalidDomainFormatException
        ):
            db.rollback()
            raise
        except Exception as e:
            db.rollback()
            logger.error(f"Error adding domain: {str(e)}")
            raise ValidationException("Failed to add domain")

    def get_vendor_domains(
        self,
        db: Session,
        vendor_id: int
    ) -> List[VendorDomain]:
        """
        Get all domains for a vendor.

        Args:
            db: Database session
            vendor_id: Vendor ID

        Returns:
            List of VendorDomain objects

        Raises:
            VendorNotFoundException: If vendor not found
        """
        try:
            # Verify vendor exists
            self._get_vendor_by_id_or_raise(db, vendor_id)

            domains = db.query(VendorDomain).filter(
                VendorDomain.vendor_id == vendor_id
            ).order_by(
                VendorDomain.is_primary.desc(),
                VendorDomain.created_at.desc()
            ).all()

            return domains

        except VendorNotFoundException:
            raise
        except Exception as e:
            logger.error(f"Error getting vendor domains: {str(e)}")
            raise ValidationException("Failed to retrieve domains")

    def update_domain(
        self,
        db: Session,
        domain_id: int,
        domain_update: VendorDomainUpdate
    ) -> VendorDomain:
        """
        Update domain settings.

        Args:
            db: Database session
            domain_id: Domain ID
            domain_update: Update data

        Returns:
            Updated VendorDomain object

        Raises:
            VendorDomainNotFoundException: If domain not found
            DomainNotVerifiedException: If trying to activate unverified domain
        """
        try:
            domain = self._get_domain_by_id_or_raise(db, domain_id)

            # If setting as primary, unset other primary domains
            if domain_update.is_primary:
                self._unset_primary_domains(db, domain.vendor_id, exclude_domain_id=domain_id)
                domain.is_primary = True

            # If activating, check verification
            if domain_update.is_active is True and not domain.is_verified:
                raise DomainNotVerifiedException(domain_id, domain.domain)

            # Update fields
            if domain_update.is_active is not None:
                domain.is_active = domain_update.is_active

            db.commit()
            db.refresh(domain)

            logger.info(f"Domain {domain.domain} updated")
            return domain

        except (VendorDomainNotFoundException, DomainNotVerifiedException):
            db.rollback()
            raise
        except Exception as e:
            db.rollback()
            logger.error(f"Error updating domain: {str(e)}")
            raise ValidationException("Failed to update domain")

    def delete_domain(
        self,
        db: Session,
        domain_id: int
    ) -> str:
        """
        Delete a custom domain.

        Args:
            db: Database session
            domain_id: Domain ID

        Returns:
            Success message

        Raises:
            VendorDomainNotFoundException: If domain not found
        """
        try:
            domain = self._get_domain_by_id_or_raise(db, domain_id)
            domain_name = domain.domain
            vendor_id = domain.vendor_id

            db.delete(domain)
            db.commit()

            logger.info(f"Domain {domain_name} deleted from vendor {vendor_id}")
            return f"Domain {domain_name} deleted successfully"

        except VendorDomainNotFoundException:
            db.rollback()
            raise
        except Exception as e:
            db.rollback()
            logger.error(f"Error deleting domain: {str(e)}")
            raise ValidationException("Failed to delete domain")

    # ========================================================================
    # Private Helper Methods (use _ prefix)
    # ========================================================================

    def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
        """Get vendor by ID or raise exception."""
        vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
        if not vendor:
            raise VendorNotFoundException(str(vendor_id), identifier_type="id")
        return vendor

    def _get_domain_by_id_or_raise(self, db: Session, domain_id: int) -> VendorDomain:
        """Get domain by ID or raise exception."""
        domain = db.query(VendorDomain).filter(VendorDomain.id == domain_id).first()
        if not domain:
            raise VendorDomainNotFoundException(str(domain_id))
        return domain

    def _check_domain_limit(self, db: Session, vendor_id: int) -> None:
        """Check if vendor has reached maximum domain limit."""
        domain_count = db.query(VendorDomain).filter(
            VendorDomain.vendor_id == vendor_id
        ).count()

        if domain_count >= self.max_domains_per_vendor:
            raise MaxDomainsReachedException(vendor_id, self.max_domains_per_vendor)

    def _domain_exists(self, db: Session, domain: str) -> bool:
        """Check if domain already exists in system."""
        return db.query(VendorDomain).filter(
            VendorDomain.domain == domain
        ).first() is not None

    def _validate_domain_format(self, domain: str) -> None:
        """Validate domain format and check for reserved subdomains."""
        first_part = domain.split('.')[0]
        if first_part in self.reserved_subdomains:
            from app.exceptions import ReservedDomainException
            raise ReservedDomainException(domain, first_part)

    def _unset_primary_domains(
        self,
        db: Session,
        vendor_id: int,
        exclude_domain_id: Optional[int] = None
    ) -> None:
        """Unset all primary domains for vendor."""
        query = db.query(VendorDomain).filter(
            VendorDomain.vendor_id == vendor_id,
            VendorDomain.is_primary == True
        )

        if exclude_domain_id:
            query = query.filter(VendorDomain.id != exclude_domain_id)

        query.update({"is_primary": False})


# Create service instance
vendor_domain_service = VendorDomainService()

Service Layer Best Practices:

  • All business logic in service layer
  • Raise custom exceptions (not HTTPException)
  • Transaction management (commit/rollback)
  • Comprehensive logging
  • Helper methods with _ prefix
  • Detailed docstrings
  • Type hints for all methods
  • Create singleton instance at bottom

API Layer (Endpoints)

Step 1: Create API Endpoints

Location: app/api/v1/admin/{entities}.py (PLURAL)

File Naming: Use plural entity name

  • app/api/v1/admin/vendor_domains.py
  • app/api/v1/admin/vendor_domain.py

Template:

# app/api/v1/admin/vendor_domains.py
"""
Admin endpoints for managing vendor custom domains.

Follows the architecture pattern:
- Endpoints only handle HTTP layer
- Business logic in service layer
- Proper exception handling
- Pydantic schemas for validation
"""

import logging
from typing import List

from fastapi import APIRouter, Depends, Path, Body, Query
from sqlalchemy.orm import Session

from app.api.deps import get_current_admin_api, get_db
from app.services.vendor_domain_service import vendor_domain_service
from app.exceptions import VendorNotFoundException
from models.schema.vendor_domain import (
    VendorDomainCreate,
    VendorDomainUpdate,
    VendorDomainResponse,
    VendorDomainListResponse,
)
from models.database.user import User
from models.database.vendor import Vendor

router = APIRouter(prefix="/vendors")
logger = logging.getLogger(__name__)


# ============================================================================
# Helper Functions
# ============================================================================

def _get_vendor_by_id(db: Session, vendor_id: int) -> Vendor:
    """
    Helper to get vendor by ID.

    Args:
        db: Database session
        vendor_id: Vendor ID

    Returns:
        Vendor object

    Raises:
        VendorNotFoundException: If vendor not found
    """
    vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
    if not vendor:
        raise VendorNotFoundException(str(vendor_id), identifier_type="id")
    return vendor


# ============================================================================
# Endpoints
# ============================================================================

@router.post("/{vendor_id}/domains", response_model=VendorDomainResponse)
def add_vendor_domain(
    vendor_id: int = Path(..., description="Vendor ID", gt=0),
    domain_data: VendorDomainCreate = Body(...),
    db: Session = Depends(get_db),
    current_admin: User = Depends(get_current_admin_api),
):
    """
    Add a custom domain to vendor (Admin only).

    This endpoint:
    1. Validates the domain format
    2. Checks if domain is already registered
    3. Generates verification token
    4. Creates domain record (unverified, inactive)
    5. Returns domain with verification instructions

    **Domain Examples:**
    - myshop.com
    - shop.mybrand.com
    - customstore.net

    **Raises:**
    - 404: Vendor not found
    - 409: Domain already registered
    - 422: Invalid domain format or reserved subdomain
    """
    domain = vendor_domain_service.add_domain(
        db=db,
        vendor_id=vendor_id,
        domain_data=domain_data
    )

    return VendorDomainResponse(
        id=domain.id,
        vendor_id=domain.vendor_id,
        domain=domain.domain,
        is_primary=domain.is_primary,
        is_active=domain.is_active,
        is_verified=domain.is_verified,
        ssl_status=domain.ssl_status,
        verification_token=domain.verification_token,
        verified_at=domain.verified_at,
        ssl_verified_at=domain.ssl_verified_at,
        created_at=domain.created_at,
        updated_at=domain.updated_at,
    )


@router.get("/{vendor_id}/domains", response_model=VendorDomainListResponse)
def list_vendor_domains(
    vendor_id: int = Path(..., description="Vendor ID", gt=0),
    db: Session = Depends(get_db),
    current_admin: User = Depends(get_current_admin_api),
):
    """
    List all domains for a vendor (Admin only).

    Returns domains ordered by:
    1. Primary domains first
    2. Creation date (newest first)

    **Raises:**
    - 404: Vendor not found
    """
    # Verify vendor exists
    _get_vendor_by_id(db, vendor_id)

    domains = vendor_domain_service.get_vendor_domains(db, vendor_id)

    return VendorDomainListResponse(
        domains=[
            VendorDomainResponse(
                id=d.id,
                vendor_id=d.vendor_id,
                domain=d.domain,
                is_primary=d.is_primary,
                is_active=d.is_active,
                is_verified=d.is_verified,
                ssl_status=d.ssl_status,
                verification_token=d.verification_token if not d.is_verified else None,
                verified_at=d.verified_at,
                ssl_verified_at=d.ssl_verified_at,
                created_at=d.created_at,
                updated_at=d.updated_at,
            )
            for d in domains
        ],
        total=len(domains)
    )


@router.put("/domains/{domain_id}", response_model=VendorDomainResponse)
def update_vendor_domain(
    domain_id: int = Path(..., description="Domain ID", gt=0),
    domain_update: VendorDomainUpdate = Body(...),
    db: Session = Depends(get_db),
    current_admin: User = Depends(get_current_admin_api),
):
    """
    Update domain settings (Admin only).

    **Can update:**
    - `is_primary`: Set as primary domain for vendor
    - `is_active`: Activate or deactivate domain

    **Important:**
    - Cannot activate unverified domains
    - Setting a domain as primary will unset other primary domains

    **Raises:**
    - 404: Domain not found
    - 400: Cannot activate unverified domain
    """
    domain = vendor_domain_service.update_domain(
        db=db,
        domain_id=domain_id,
        domain_update=domain_update
    )

    return VendorDomainResponse(
        id=domain.id,
        vendor_id=domain.vendor_id,
        domain=domain.domain,
        is_primary=domain.is_primary,
        is_active=domain.is_active,
        is_verified=domain.is_verified,
        ssl_status=domain.ssl_status,
        verification_token=None,
        verified_at=domain.verified_at,
        ssl_verified_at=domain.ssl_verified_at,
        created_at=domain.created_at,
        updated_at=domain.updated_at,
    )


@router.delete("/domains/{domain_id}")
def delete_vendor_domain(
    domain_id: int = Path(..., description="Domain ID", gt=0),
    db: Session = Depends(get_db),
    current_admin: User = Depends(get_current_admin_api),
):
    """
    Delete a custom domain (Admin only).

    **Warning:** This is permanent and cannot be undone.
    
    **Raises:**
    - 404: Domain not found
    """
    message = vendor_domain_service.delete_domain(db, domain_id)
    return {"message": message}

Endpoint Best Practices:

  • Use plural file names for collections
  • Prefix for related resources (/vendors/{id}/domains)
  • Path parameters with validation (gt=0)
  • Comprehensive docstrings with examples
  • Delegate to service layer immediately
  • No business logic in endpoints
  • Proper dependency injection
  • Response models for consistency

Step 2: Register Router

Location: app/api/v1/admin/__init__.py

# app/api/v1/admin/__init__.py

from fastapi import APIRouter
from . import (
    auth,
    vendors,
    vendor_domains,  # NEW
    users,
    dashboard,
    # ... other routers
)

router = APIRouter()

# Include routers
router.include_router(auth.router, tags=["admin-auth"])
router.include_router(vendors.router, tags=["admin-vendors"])
router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])  # NEW
router.include_router(users.router, tags=["admin-users"])
# ... other routers

Frontend Layer

Step 1: Create HTML Page Route

Location: app/api/v1/admin/pages.py

# app/api/v1/admin/pages.py

@router.get("/vendors/{vendor_code}/domains", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendor_domains_page(
        request: Request,
        vendor_code: str = Path(..., description="Vendor code"),
        current_user: User = Depends(get_current_admin_from_cookie_or_header),
        db: Session = Depends(get_db)
):
    """
    Render vendor domains management page.
    Shows custom domains, verification status, and DNS configuration.
    """
    return templates.TemplateResponse(
        "admin/vendor-domains.html",
        {
            "request": request,
            "user": current_user,
            "vendor_code": vendor_code,
        }
    )

Step 2: Create HTML Template

Location: app/templates/admin/vendor-domains.html

{% extends "admin/base.html" %}

{% block title %}Vendor Domains - {{ vendor_code }}{% endblock %}

{% block content %}
<div class="container mx-auto px-6 py-8">
    <div class="flex items-center justify-between mb-6">
        <div>
            <h1 class="text-3xl font-semibold text-gray-800 dark:text-white">
                Custom Domains
            </h1>
            <p class="text-gray-600 dark:text-gray-400 mt-2">
                Manage custom domains for {{ vendor_code }}
            </p>
        </div>
        <button 
            onclick="openAddDomainModal()"
            class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
            Add Domain
        </button>
    </div>

    <!-- Domains List -->
    <div id="domainsList" class="space-y-4">
        <!-- Dynamically loaded via JavaScript -->
    </div>
</div>

<!-- Add Domain Modal -->
<div id="addDomainModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50">
    <div class="flex items-center justify-center min-h-screen px-4">
        <div class="bg-white dark:bg-gray-800 rounded-lg max-w-md w-full p-6">
            <h2 class="text-2xl font-semibold mb-4">Add Custom Domain</h2>
            
            <form id="addDomainForm">
                <div class="mb-4">
                    <label class="block text-sm font-medium mb-2">Domain</label>
                    <input 
                        type="text" 
                        name="domain"
                        placeholder="myshop.com"
                        class="w-full px-3 py-2 border rounded-lg"
                        required>
                </div>
                
                <div class="mb-4">
                    <label class="flex items-center">
                        <input type="checkbox" name="is_primary" class="mr-2">
                        <span class="text-sm">Set as primary domain</span>
                    </label>
                </div>
                
                <div class="flex gap-2">
                    <button 
                        type="submit"
                        class="flex-1 bg-purple-600 text-white py-2 rounded-lg hover:bg-purple-700">
                        Add Domain
                    </button>
                    <button 
                        type="button"
                        onclick="closeAddDomainModal()"
                        class="flex-1 bg-gray-300 text-gray-700 py-2 rounded-lg hover:bg-gray-400">
                        Cancel
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

<script>
// Get vendor ID from URL
const vendorCode = "{{ vendor_code }}";
let vendorId = null;

// Load domains on page load
document.addEventListener('DOMContentLoaded', async () => {
    // First, get vendor ID from vendor code
    await loadVendorId();
    // Then load domains
    await loadDomains();
});

async function loadVendorId() {
    try {
        const response = await fetch(`/api/v1/admin/vendors/${vendorCode}`, {
            headers: {
                'Authorization': `Bearer ${localStorage.getItem('token')}`
            }
        });
        
        if (response.ok) {
            const vendor = await response.json();
            vendorId = vendor.id;
        }
    } catch (error) {
        console.error('Error loading vendor:', error);
    }
}

async function loadDomains() {
    if (!vendorId) return;
    
    try {
        const response = await fetch(`/api/v1/admin/vendors/${vendorId}/domains`, {
            headers: {
                'Authorization': `Bearer ${localStorage.getItem('token')}`
            }
        });
        
        if (response.ok) {
            const data = await response.json();
            renderDomains(data.domains);
        }
    } catch (error) {
        console.error('Error loading domains:', error);
    }
}

function renderDomains(domains) {
    const container = document.getElementById('domainsList');
    
    if (domains.length === 0) {
        container.innerHTML = '<p class="text-gray-500">No domains added yet.</p>';
        return;
    }
    
    container.innerHTML = domains.map(domain => `
        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <div class="flex items-center justify-between">
                <div class="flex-1">
                    <div class="flex items-center gap-2">
                        <h3 class="text-lg font-semibold">${domain.domain}</h3>
                        ${domain.is_primary ? '<span class="px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded">Primary</span>' : ''}
                        ${domain.is_verified ? '<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">Verified</span>' : '<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded">Unverified</span>'}
                        ${domain.is_active ? '<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">Active</span>' : '<span class="px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded">Inactive</span>'}
                    </div>
                    <p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
                        Added ${new Date(domain.created_at).toLocaleDateString()}
                    </p>
                </div>
                <div class="flex gap-2">
                    ${!domain.is_verified ? `
                        <button 
                            onclick="verifyDomain(${domain.id})"
                            class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
                            Verify
                        </button>
                    ` : ''}
                    <button 
                        onclick="deleteDomain(${domain.id})"
                        class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
                        Delete
                    </button>
                </div>
            </div>
        </div>
    `).join('');
}

function openAddDomainModal() {
    document.getElementById('addDomainModal').classList.remove('hidden');
}

function closeAddDomainModal() {
    document.getElementById('addDomainModal').classList.add('hidden');
    document.getElementById('addDomainForm').reset();
}

document.getElementById('addDomainForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const formData = new FormData(e.target);
    const data = {
        domain: formData.get('domain'),
        is_primary: formData.get('is_primary') === 'on'
    };
    
    try {
        const response = await fetch(`/api/v1/admin/vendors/${vendorId}/domains`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${localStorage.getItem('token')}`
            },
            body: JSON.stringify(data)
        });
        
        if (response.ok) {
            closeAddDomainModal();
            await loadDomains();
            alert('Domain added successfully!');
        } else {
            const error = await response.json();
            alert(`Error: ${error.message}`);
        }
    } catch (error) {
        console.error('Error adding domain:', error);
        alert('Failed to add domain');
    }
});

async function verifyDomain(domainId) {
    try {
        const response = await fetch(`/api/v1/admin/vendors/domains/${domainId}/verify`, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${localStorage.getItem('token')}`
            }
        });
        
        if (response.ok) {
            await loadDomains();
            alert('Domain verified successfully!');
        } else {
            const error = await response.json();
            alert(`Verification failed: ${error.message}`);
        }
    } catch (error) {
        console.error('Error verifying domain:', error);
        alert('Failed to verify domain');
    }
}

async function deleteDomain(domainId) {
    if (!confirm('Are you sure you want to delete this domain?')) return;
    
    try {
        const response = await fetch(`/api/v1/admin/vendors/domains/${domainId}`, {
            method: 'DELETE',
            headers: {
                'Authorization': `Bearer ${localStorage.getItem('token')}`
            }
        });
        
        if (response.ok) {
            await loadDomains();
            alert('Domain deleted successfully!');
        } else {
            const error = await response.json();
            alert(`Error: ${error.message}`);
        }
    } catch (error) {
        console.error('Error deleting domain:', error);
        alert('Failed to delete domain');
    }
}
</script>
{% endblock %}

Testing

Step 1: Unit Tests (Service Layer)

Location: tests/unit/services/test_vendor_domain_service.py

# tests/unit/services/test_vendor_domain_service.py
import pytest
from app.services.vendor_domain_service import VendorDomainService
from app.exceptions import (
    VendorDomainAlreadyExistsException,
    MaxDomainsReachedException,
    InvalidDomainFormatException
)
from models.schema.vendor_domain import VendorDomainCreate


@pytest.fixture
def service():
    return VendorDomainService()


def test_add_domain_success(db_session, test_vendor, service):
    """Test successful domain addition."""
    domain_data = VendorDomainCreate(
        domain="test.com",
        is_primary=True
    )
    
    domain = service.add_domain(db_session, test_vendor.id, domain_data)
    
    assert domain.domain == "test.com"
    assert domain.is_primary is True
    assert domain.is_verified is False
    assert domain.verification_token is not None


def test_add_domain_already_exists(db_session, test_vendor, existing_domain, service):
    """Test adding duplicate domain raises exception."""
    domain_data = VendorDomainCreate(domain=existing_domain.domain)
    
    with pytest.raises(VendorDomainAlreadyExistsException):
        service.add_domain(db_session, test_vendor.id, domain_data)


def test_add_domain_max_limit_reached(db_session, test_vendor, service):
    """Test max domains limit enforcement."""
    # Add max_domains domains
    for i in range(service.max_domains_per_vendor):
        domain_data = VendorDomainCreate(domain=f"test{i}.com")
        service.add_domain(db_session, test_vendor.id, domain_data)
    
    # Try adding one more
    domain_data = VendorDomainCreate(domain="overflow.com")
    with pytest.raises(MaxDomainsReachedException):
        service.add_domain(db_session, test_vendor.id, domain_data)


def test_domain_normalization(service):
    """Test domain normalization."""
    assert service._normalize_domain("HTTP://TEST.COM/") == "test.com"
    assert service._normalize_domain("WWW.Test.COM") == "www.test.com"

Step 2: Integration Tests (API Endpoints)

Location: tests/integration/api/v1/admin/test_vendor_domains.py

# tests/integration/api/v1/admin/test_vendor_domains.py
import pytest


def test_add_domain_endpoint(client, admin_headers, test_vendor):
    """Test domain addition endpoint."""
    response = client.post(
        f"/api/v1/admin/vendors/{test_vendor.id}/domains",
        json={"domain": "newshop.com", "is_primary": False},
        headers=admin_headers
    )
    
    assert response.status_code == 201
    data = response.json()
    assert data["domain"] == "newshop.com"
    assert data["is_verified"] is False


def test_add_domain_invalid_format(client, admin_headers, test_vendor):
    """Test adding domain with invalid format."""
    response = client.post(
        f"/api/v1/admin/vendors/{test_vendor.id}/domains",
        json={"domain": "admin.example.com"},  # Reserved subdomain
        headers=admin_headers
    )
    
    assert response.status_code == 422
    assert "reserved" in response.json()["message"].lower()


def test_list_domains_endpoint(client, admin_headers, test_vendor, test_domain):
    """Test listing vendor domains."""
    response = client.get(
        f"/api/v1/admin/vendors/{test_vendor.id}/domains",
        headers=admin_headers
    )
    
    assert response.status_code == 200
    data = response.json()
    assert data["total"] >= 1
    assert len(data["domains"]) >= 1


def test_delete_domain_endpoint(client, admin_headers, test_domain):
    """Test domain deletion."""
    response = client.delete(
        f"/api/v1/admin/vendors/domains/{test_domain.id}",
        headers=admin_headers
    )
    
    assert response.status_code == 200
    assert "deleted successfully" in response.json()["message"]


def test_verify_domain_not_found(client, admin_headers):
    """Test verification with non-existent domain."""
    response = client.post(
        "/api/v1/admin/vendors/domains/99999/verify",
        headers=admin_headers
    )
    
    assert response.status_code == 404
    assert response.json()["error_code"] == "VENDOR_DOMAIN_NOT_FOUND"

Documentation

Step 1: Update API Documentation

Location: docs/api/admin_vendor_domains.md

# Vendor Domains API

## Overview

Custom domain management for vendors.

## Endpoints

### Add Domain
\`\`\`
POST /api/v1/admin/vendors/{vendor_id}/domains
\`\`\`

**Request:**
\`\`\`json
{
  "domain": "myshop.com",
  "is_primary": true
}
\`\`\`

**Response:** `201 Created`
\`\`\`json
{
  "id": 1,
  "domain": "myshop.com",
  "is_verified": false,
  "verification_token": "abc123..."
}
\`\`\`

### List Domains
\`\`\`
GET /api/v1/admin/vendors/{vendor_id}/domains
\`\`\`

### Verify Domain
\`\`\`
POST /api/v1/admin/vendors/domains/{domain_id}/verify
\`\`\`

### Delete Domain
\`\`\`
DELETE /api/v1/admin/vendors/domains/{domain_id}
\`\`\`

Step 2: Update Changelog

Location: CHANGELOG.md

## [Unreleased]

### Added
- Custom domain support for vendors
- DNS verification system
- Domain management UI in admin panel

### API Changes
- Added `/api/v1/admin/vendors/{id}/domains` endpoints
- Added domain verification endpoints

Deployment Checklist

Pre-Deployment

  • All unit tests passing
  • All integration tests passing
  • Database migration created and tested
  • Exception handling tested
  • API documentation updated
  • Frontend UI tested
  • Code review completed
  • Changelog updated

Deployment Steps

  1. Database Migration

    alembic upgrade head
    
  2. Verify Migration

    # Check table exists
    SELECT * FROM vendor_domains LIMIT 1;
    
  3. Deploy Code

    git push origin main
    # Deploy via your CI/CD pipeline
    
  4. Verify Deployment

    # Test API endpoint
    curl -X GET https://your-domain.com/api/v1/admin/vendors/1/domains \
      -H "Authorization: Bearer TOKEN"
    
  5. Monitor Logs

    # Check for errors
    tail -f /var/log/app.log | grep ERROR
    

Post-Deployment

  • API endpoints accessible
  • Frontend pages loading
  • Database queries performing well
  • No error logs
  • User acceptance testing completed
  • Documentation deployed

Quick Reference

File Locations Checklist

✅ models/database/vendor_domain.py        # Database model
✅ models/schema/vendor_domain.py          # Pydantic schemas
✅ app/exceptions/vendor_domain.py         # Custom exceptions
✅ app/services/vendor_domain_service.py   # Business logic
✅ app/api/v1/admin/vendor_domains.py      # API endpoints
✅ app/api/v1/admin/pages.py               # HTML routes
✅ app/templates/admin/vendor-domains.html # HTML template
✅ tests/unit/services/test_vendor_domain_service.py
✅ tests/integration/api/v1/admin/test_vendor_domains.py

Naming Convention Summary

Type Naming Example
API File PLURAL vendor_domains.py
Model File SINGULAR vendor_domain.py
Schema File SINGULAR vendor_domain.py
Service File SINGULAR + service vendor_domain_service.py
Exception File SINGULAR vendor_domain.py
Class Name SINGULAR VendorDomain
Table Name PLURAL vendor_domains

Common Pitfalls to Avoid

Don't: Put business logic in endpoints Do: Put business logic in service layer

Don't: Raise HTTPException from services Do: Raise custom exceptions

Don't: Access database directly in endpoints Do: Call service methods

Don't: Use plural for model files Do: Use singular for model files

Don't: Skip validation Do: Use Pydantic validators

Don't: Forget transaction management Do: Use try/except with commit/rollback


Support

For questions or issues:

  1. Check this guide
  2. Review exception-handling.md
  3. Reference 6_complete_naming_convention.md
  4. Review existing implementations (vendors, users)

Last Updated: 2025-01-15
Version: 1.0
Maintainer: Development Team