# 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](#planning-phase) 2. [Database Layer](#database-layer) 3. [Schema Layer (Pydantic)](#schema-layer-pydantic) 4. [Exception Layer](#exception-layer) 5. [Service Layer](#service-layer) 6. [API Layer (Endpoints)](#api-layer-endpoints) 7. [Frontend Layer](#frontend-layer) 8. [Testing](#testing) 9. [Documentation](#documentation) 10. [Deployment Checklist](#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 "Store Domains" Feature** ``` Feature Name: Store Domains Purpose: Allow stores to use custom domains Operations: Create, Read, Update, Delete, Verify Relationships: Belongs to Store Validation: Domain format, uniqueness, DNS verification Permissions: Admin only ``` ### 2. Review Naming Conventions **Consult:** `6_complete_naming_convention.md` **Key Rules:** - **Collections/Endpoints**: PLURAL (`stores.py`, `domains.py`) - **Entities/Models**: SINGULAR (`store.py`, `domain.py`) - **Services**: SINGULAR + "service" (`store_service.py`, `domain_service.py`) - **Exceptions**: SINGULAR domain name (`store.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/store_domain.py` - ❌ `models/database/store_domains.py` **Template:** ```python # models/database/store_domain.py """ StoreDomain database model. This model represents custom domains for stores. """ 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 StoreDomain(Base, TimestampMixin): """ Custom domain mapping for stores. Allows stores to use their own domains (e.g., myshop.com) instead of subdomains (store1.platform.com). """ __tablename__ = "store_domains" # PLURAL table name # Primary Key id = Column(Integer, primary_key=True, index=True) # Foreign Keys store_id = Column( Integer, ForeignKey("stores.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 store = relationship("Store", back_populates="domains") # Constraints and Indexes __table_args__ = ( UniqueConstraint('store_id', 'domain', name='uq_store_domain'), Index('idx_domain_active', 'domain', 'is_active'), Index('idx_store_primary', 'store_id', 'is_primary'), ) def __repr__(self): return f"" # 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 ### Step 2: Update Related Models **Location:** Update the parent model (e.g., `models/database/store.py`) ```python # models/database/store.py class Store(Base, TimestampMixin): __tablename__ = "stores" # ... existing fields ... # Add relationship domains = relationship( "StoreDomain", back_populates="store", cascade="all, delete-orphan", order_by="StoreDomain.is_primary.desc()" ) # Helper method @property def primary_domain(self): """Get the primary custom domain for this store.""" for domain in self.domains: if domain.is_primary and domain.is_active: return domain.domain return None ``` ### Step 3: Create Database Migration ```bash # Create migration alembic revision --autogenerate -m "Add store_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/store_domain.py` - ❌ `models/schema/store_domains.py` **Template:** ```python # models/schema/store_domain.py """ Pydantic schemas for StoreDomain operations. Schemas include: - StoreDomainCreate: For adding custom domains - StoreDomainUpdate: For updating domain settings - StoreDomainResponse: Standard domain response - StoreDomainListResponse: 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 StoreDomainCreate(BaseModel): """Schema for adding a custom domain to store.""" 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 store" ) @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 StoreDomainUpdate(BaseModel): """Schema for updating store 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 StoreDomainResponse(BaseModel): """Standard schema for store domain response.""" model_config = ConfigDict(from_attributes=True) id: int store_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 StoreDomainListResponse(BaseModel): """Schema for paginated store domain list.""" domains: List[StoreDomainResponse] 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/store_domain.py` - ❌ `app/exceptions/store_domains.py` **Template:** ```python # app/exceptions/store_domain.py """ Store 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 StoreDomainNotFoundException(ResourceNotFoundException): """Raised when a store 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="StoreDomain", identifier=domain_identifier, message=message, error_code="STORE_DOMAIN_NOT_FOUND", ) # ============================================================================ # Conflicts (409) # ============================================================================ class StoreDomainAlreadyExistsException(ConflictException): """Raised when trying to add a domain that already exists.""" def __init__(self, domain: str, existing_store_id: Optional[int] = None): details = {"domain": domain} if existing_store_id: details["existing_store_id"] = existing_store_id super().__init__( message=f"Domain '{domain}' is already registered", error_code="STORE_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 store tries to add more domains than allowed.""" def __init__(self, store_id: int, max_domains: int): super().__init__( message=f"Maximum number of domains reached ({max_domains})", error_code="MAX_DOMAINS_REACHED", details={"store_id": store_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 | `OrionException` | Generic errors | | 502 | `ExternalServiceException` | Third-party failures | ### Step 2: Update Exception Exports **Location:** `app/exceptions/__init__.py` ```python # app/exceptions/__init__.py # ... existing imports ... # Store domain exceptions from .store_domain import ( StoreDomainNotFoundException, StoreDomainAlreadyExistsException, InvalidDomainFormatException, ReservedDomainException, DomainNotVerifiedException, DomainVerificationFailedException, MaxDomainsReachedException, DNSVerificationException, ) __all__ = [ # ... existing exports ... # Store Domain "StoreDomainNotFoundException", "StoreDomainAlreadyExistsException", "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/store_domain_service.py` - ❌ `app/services/store_domains_service.py` **Template:** ```python # app/services/store_domain_service.py """ Store 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 ( StoreNotFoundException, StoreDomainNotFoundException, StoreDomainAlreadyExistsException, InvalidDomainFormatException, DomainNotVerifiedException, DomainVerificationFailedException, MaxDomainsReachedException, ValidationException, ) from models.schema.store_domain import StoreDomainCreate, StoreDomainUpdate from models.database.store import Store from models.database.store_domain import StoreDomain logger = logging.getLogger(__name__) class StoreDomainService: """Service class for store domain operations.""" def __init__(self): """Initialize service with configuration.""" self.max_domains_per_store = 10 self.reserved_subdomains = ['www', 'admin', 'api', 'mail', 'smtp', 'ftp'] def add_domain( self, db: Session, store_id: int, domain_data: StoreDomainCreate ) -> StoreDomain: """ Add a custom domain to store. Args: db: Database session store_id: Store ID to add domain to domain_data: Domain creation data Returns: Created StoreDomain object Raises: StoreNotFoundException: If store not found StoreDomainAlreadyExistsException: If domain already registered MaxDomainsReachedException: If store has reached max domains InvalidDomainFormatException: If domain format is invalid """ try: # 1. Verify store exists store = self._get_store_by_id_or_raise(db, store_id) # 2. Check domain limit self._check_domain_limit(db, store_id) # 3. Normalize domain normalized_domain = StoreDomain.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(StoreDomain).filter( StoreDomain.domain == normalized_domain ).first() raise StoreDomainAlreadyExistsException( normalized_domain, existing.store_id if existing else None ) # 6. If setting as primary, unset other primary domains if domain_data.is_primary: self._unset_primary_domains(db, store_id) # 7. Create domain record new_domain = StoreDomain( store_id=store_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 store {store_id}") return new_domain except ( StoreNotFoundException, StoreDomainAlreadyExistsException, 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_store_domains( self, db: Session, store_id: int ) -> List[StoreDomain]: """ Get all domains for a store. Args: db: Database session store_id: Store ID Returns: List of StoreDomain objects Raises: StoreNotFoundException: If store not found """ try: # Verify store exists self._get_store_by_id_or_raise(db, store_id) domains = db.query(StoreDomain).filter( StoreDomain.store_id == store_id ).order_by( StoreDomain.is_primary.desc(), StoreDomain.created_at.desc() ).all() return domains except StoreNotFoundException: raise except Exception as e: logger.error(f"Error getting store domains: {str(e)}") raise ValidationException("Failed to retrieve domains") def update_domain( self, db: Session, domain_id: int, domain_update: StoreDomainUpdate ) -> StoreDomain: """ Update domain settings. Args: db: Database session domain_id: Domain ID domain_update: Update data Returns: Updated StoreDomain object Raises: StoreDomainNotFoundException: 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.store_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 (StoreDomainNotFoundException, 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: StoreDomainNotFoundException: If domain not found """ try: domain = self._get_domain_by_id_or_raise(db, domain_id) domain_name = domain.domain store_id = domain.store_id db.delete(domain) db.commit() logger.info(f"Domain {domain_name} deleted from store {store_id}") return f"Domain {domain_name} deleted successfully" except StoreDomainNotFoundException: 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_store_by_id_or_raise(self, db: Session, store_id: int) -> Store: """Get store by ID or raise exception.""" store = db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") return store def _get_domain_by_id_or_raise(self, db: Session, domain_id: int) -> StoreDomain: """Get domain by ID or raise exception.""" domain = db.query(StoreDomain).filter(StoreDomain.id == domain_id).first() if not domain: raise StoreDomainNotFoundException(str(domain_id)) return domain def _check_domain_limit(self, db: Session, store_id: int) -> None: """Check if store has reached maximum domain limit.""" domain_count = db.query(StoreDomain).filter( StoreDomain.store_id == store_id ).count() if domain_count >= self.max_domains_per_store: raise MaxDomainsReachedException(store_id, self.max_domains_per_store) def _domain_exists(self, db: Session, domain: str) -> bool: """Check if domain already exists in system.""" return db.query(StoreDomain).filter( StoreDomain.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, store_id: int, exclude_domain_id: Optional[int] = None ) -> None: """Unset all primary domains for store.""" query = db.query(StoreDomain).filter( StoreDomain.store_id == store_id, StoreDomain.is_primary == True ) if exclude_domain_id: query = query.filter(StoreDomain.id != exclude_domain_id) query.update({"is_primary": False}) # Create service instance store_domain_service = StoreDomainService() ``` **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/store_domains.py` - ❌ `app/api/v1/admin/store_domain.py` **Template:** ```python # app/api/v1/admin/store_domains.py """ Admin endpoints for managing store 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.store_domain_service import store_domain_service from app.exceptions import StoreNotFoundException from models.schema.store_domain import ( StoreDomainCreate, StoreDomainUpdate, StoreDomainResponse, StoreDomainListResponse, ) from models.database.user import User from models.database.store import Store router = APIRouter(prefix="/stores") logger = logging.getLogger(__name__) # ============================================================================ # Helper Functions # ============================================================================ def _get_store_by_id(db: Session, store_id: int) -> Store: """ Helper to get store by ID. Args: db: Database session store_id: Store ID Returns: Store object Raises: StoreNotFoundException: If store not found """ store = db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(str(store_id), identifier_type="id") return store # ============================================================================ # Endpoints # ============================================================================ @router.post("/{store_id}/domains", response_model=StoreDomainResponse) def add_store_domain( store_id: int = Path(..., description="Store ID", gt=0), domain_data: StoreDomainCreate = Body(...), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Add a custom domain to store (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: Store not found - 409: Domain already registered - 422: Invalid domain format or reserved subdomain """ domain = store_domain_service.add_domain( db=db, store_id=store_id, domain_data=domain_data ) return StoreDomainResponse( id=domain.id, store_id=domain.store_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("/{store_id}/domains", response_model=StoreDomainListResponse) def list_store_domains( store_id: int = Path(..., description="Store ID", gt=0), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ List all domains for a store (Admin only). Returns domains ordered by: 1. Primary domains first 2. Creation date (newest first) **Raises:** - 404: Store not found """ # Verify store exists _get_store_by_id(db, store_id) domains = store_domain_service.get_store_domains(db, store_id) return StoreDomainListResponse( domains=[ StoreDomainResponse( id=d.id, store_id=d.store_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=StoreDomainResponse) def update_store_domain( domain_id: int = Path(..., description="Domain ID", gt=0), domain_update: StoreDomainUpdate = 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 store - `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 = store_domain_service.update_domain( db=db, domain_id=domain_id, domain_update=domain_update ) return StoreDomainResponse( id=domain.id, store_id=domain.store_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_store_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 = store_domain_service.delete_domain(db, domain_id) return {"message": message} ``` **Endpoint Best Practices:** - ✅ Use plural file names for collections - ✅ Prefix for related resources (`/stores/{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` ```python # app/api/v1/admin/__init__.py from fastapi import APIRouter from . import ( auth, stores, store_domains, # NEW users, dashboard, # ... other routers ) router = APIRouter() # Include routers router.include_router(auth.router, tags=["admin-auth"]) router.include_router(stores.router, tags=["admin-stores"]) router.include_router(store_domains.router, tags=["admin-store-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` ```python # app/api/v1/admin/pages.py @router.get("/stores/{store_code}/domains", response_class=HTMLResponse, include_in_schema=False) async def admin_store_domains_page( request: Request, store_code: str = Path(..., description="Store code"), current_user: User = Depends(get_current_admin_from_cookie_or_header), db: Session = Depends(get_db) ): """ Render store domains management page. Shows custom domains, verification status, and DNS configuration. """ return templates.TemplateResponse( "admin/store-domains.html", { "request": request, "user": current_user, "store_code": store_code, } ) ``` ### Step 2: Create HTML Template **Location:** `app/templates/admin/store-domains.html` ```html {% extends "admin/base.html" %} {% block title %}Store Domains - {{ store_code }}{% endblock %} {% block content %}

Custom Domains

Manage custom domains for {{ store_code }}

{% endblock %} ``` --- ## Testing ### Step 1: Unit Tests (Service Layer) **Location:** `tests/unit/services/test_store_domain_service.py` ```python # tests/unit/services/test_store_domain_service.py import pytest from app.services.store_domain_service import StoreDomainService from app.exceptions import ( StoreDomainAlreadyExistsException, MaxDomainsReachedException, InvalidDomainFormatException ) from models.schema.store_domain import StoreDomainCreate @pytest.fixture def service(): return StoreDomainService() def test_add_domain_success(db_session, test_store, service): """Test successful domain addition.""" domain_data = StoreDomainCreate( domain="test.com", is_primary=True ) domain = service.add_domain(db_session, test_store.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_store, existing_domain, service): """Test adding duplicate domain raises exception.""" domain_data = StoreDomainCreate(domain=existing_domain.domain) with pytest.raises(StoreDomainAlreadyExistsException): service.add_domain(db_session, test_store.id, domain_data) def test_add_domain_max_limit_reached(db_session, test_store, service): """Test max domains limit enforcement.""" # Add max_domains domains for i in range(service.max_domains_per_store): domain_data = StoreDomainCreate(domain=f"test{i}.com") service.add_domain(db_session, test_store.id, domain_data) # Try adding one more domain_data = StoreDomainCreate(domain="overflow.com") with pytest.raises(MaxDomainsReachedException): service.add_domain(db_session, test_store.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_store_domains.py` ```python # tests/integration/api/v1/admin/test_store_domains.py import pytest def test_add_domain_endpoint(client, admin_headers, test_store): """Test domain addition endpoint.""" response = client.post( f"/api/v1/admin/stores/{test_store.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_store): """Test adding domain with invalid format.""" response = client.post( f"/api/v1/admin/stores/{test_store.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_store, test_domain): """Test listing store domains.""" response = client.get( f"/api/v1/admin/stores/{test_store.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/stores/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/stores/domains/99999/verify", headers=admin_headers ) assert response.status_code == 404 assert response.json()["error_code"] == "STORE_DOMAIN_NOT_FOUND" ``` --- ## Documentation ### Step 1: Update API Documentation **Location:** `docs/api/admin_store_domains.md` ```markdown # Store Domains API ## Overview Custom domain management for stores. ## Endpoints ### Add Domain \`\`\` POST /api/v1/admin/stores/{store_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/stores/{store_id}/domains \`\`\` ### Verify Domain \`\`\` POST /api/v1/admin/stores/domains/{domain_id}/verify \`\`\` ### Delete Domain \`\`\` DELETE /api/v1/admin/stores/domains/{domain_id} \`\`\` ``` ### Step 2: Update Changelog **Location:** `CHANGELOG.md` ```markdown ## [Unreleased] ### Added - Custom domain support for stores - DNS verification system - Domain management UI in admin panel ### API Changes - Added `/api/v1/admin/stores/{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** ```bash alembic upgrade head ``` 2. **Verify Migration** ```bash # Check table exists SELECT * FROM store_domains LIMIT 1; ``` 3. **Deploy Code** ```bash git push origin main # Deploy via your CI/CD pipeline ``` 4. **Verify Deployment** ```bash # Test API endpoint curl -X GET https://your-domain.com/api/v1/admin/stores/1/domains \ -H "Authorization: Bearer TOKEN" ``` 5. **Monitor Logs** ```bash # 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/store_domain.py # Database model ✅ models/schema/store_domain.py # Pydantic schemas ✅ app/exceptions/store_domain.py # Custom exceptions ✅ app/services/store_domain_service.py # Business logic ✅ app/api/v1/admin/store_domains.py # API endpoints ✅ app/api/v1/admin/pages.py # HTML routes ✅ app/templates/admin/store-domains.html # HTML template ✅ tests/unit/services/test_store_domain_service.py ✅ tests/integration/api/v1/admin/test_store_domains.py ``` ### Naming Convention Summary | Type | Naming | Example | |------|--------|---------| | API File | PLURAL | `store_domains.py` | | Model File | SINGULAR | `store_domain.py` | | Schema File | SINGULAR | `store_domain.py` | | Service File | SINGULAR + service | `store_domain_service.py` | | Exception File | SINGULAR | `store_domain.py` | | Class Name | SINGULAR | `StoreDomain` | | Table Name | PLURAL | `store_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 (stores, users) --- **Last Updated:** 2025-01-15 **Version:** 1.0 **Maintainer:** Development Team