Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart with Orion/orion/ORION across 184 files. This includes database identifiers, email addresses, domain references, R2 bucket names, DNS prefixes, encryption salt, Celery app name, config defaults, Docker configs, CI configs, documentation, seed data, and templates. Renames homepage-wizamart.html template to homepage-orion.html. Fixes duplicate file_pattern key in api.yaml architecture rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1800 lines
52 KiB
Markdown
1800 lines
52 KiB
Markdown
# 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"<StoreDomain(id={self.id}, domain='{self.domain}', store_id={self.store_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
|
|
|
|
### 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 %}
|
|
<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 {{ store_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 store ID from URL
|
|
const storeCode = "{{ store_code }}";
|
|
let storeId = null;
|
|
|
|
// Load domains on page load
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// First, get store ID from store code
|
|
await loadStoreId();
|
|
// Then load domains
|
|
await loadDomains();
|
|
});
|
|
|
|
async function loadStoreId() {
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/stores/${storeCode}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const store = await response.json();
|
|
storeId = store.id;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading store:', error);
|
|
}
|
|
}
|
|
|
|
async function loadDomains() {
|
|
if (!storeId) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/admin/stores/${storeId}/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/stores/${storeId}/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/stores/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/stores/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_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
|