feat(tenancy): add merchant-level domain with store override
Merchants can now register domains (e.g., myloyaltyprogram.lu) that all their stores inherit. Individual stores can override with their own custom domain. Resolution priority: StoreDomain > MerchantDomain > subdomain. - Add MerchantDomain model, schema, service, and admin API endpoints - Add merchant domain fallback in platform and store context middleware - Add Merchant.primary_domain and Store.effective_domain properties - Add Alembic migration for merchant_domains table - Update loyalty user journey docs with subscription & domain setup flow - Add unit tests (50 passing) and integration tests (15 passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
297
app/modules/tenancy/routes/api/admin_merchant_domains.py
Normal file
297
app/modules/tenancy/routes/api/admin_merchant_domains.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# app/modules/tenancy/routes/api/admin_merchant_domains.py
|
||||
"""
|
||||
Admin endpoints for managing merchant-level custom domains.
|
||||
|
||||
Follows the same pattern as admin_store_domains.py:
|
||||
- Endpoints only handle HTTP layer
|
||||
- Business logic in service layer
|
||||
- Domain exceptions bubble up to global handler
|
||||
- Pydantic schemas for validation
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.services.merchant_domain_service import (
|
||||
merchant_domain_service,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.tenancy.schemas.merchant_domain import (
|
||||
MerchantDomainCreate,
|
||||
MerchantDomainDeletionResponse,
|
||||
MerchantDomainListResponse,
|
||||
MerchantDomainResponse,
|
||||
MerchantDomainUpdate,
|
||||
)
|
||||
from app.modules.tenancy.schemas.store_domain import (
|
||||
DomainVerificationInstructions,
|
||||
DomainVerificationResponse,
|
||||
)
|
||||
|
||||
admin_merchant_domains_router = APIRouter(prefix="/merchants")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.post(
|
||||
"/{merchant_id}/domains", response_model=MerchantDomainResponse
|
||||
)
|
||||
def add_merchant_domain(
|
||||
merchant_id: int = Path(..., description="Merchant ID", gt=0),
|
||||
domain_data: MerchantDomainCreate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Register a merchant-level domain (Admin only).
|
||||
|
||||
The domain serves as the default for all the merchant's stores.
|
||||
Individual stores can override with their own StoreDomain.
|
||||
|
||||
**Domain Resolution Priority:**
|
||||
1. Store-specific custom domain (StoreDomain) -> highest priority
|
||||
2. Merchant domain (MerchantDomain) -> inherited default
|
||||
3. Store subdomain ({store.subdomain}.platform.lu) -> fallback
|
||||
|
||||
**Raises:**
|
||||
- 404: Merchant not found
|
||||
- 409: Domain already registered
|
||||
- 422: Invalid domain format or reserved subdomain
|
||||
"""
|
||||
domain = merchant_domain_service.add_domain(
|
||||
db=db, merchant_id=merchant_id, domain_data=domain_data
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MerchantDomainResponse(
|
||||
id=domain.id,
|
||||
merchant_id=domain.merchant_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
platform_id=domain.platform_id,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.get(
|
||||
"/{merchant_id}/domains", response_model=MerchantDomainListResponse
|
||||
)
|
||||
def list_merchant_domains(
|
||||
merchant_id: int = Path(..., description="Merchant ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all domains for a merchant (Admin only).
|
||||
|
||||
Returns domains ordered by:
|
||||
1. Primary domains first
|
||||
2. Creation date (newest first)
|
||||
|
||||
**Raises:**
|
||||
- 404: Merchant not found
|
||||
"""
|
||||
domains = merchant_domain_service.get_merchant_domains(db, merchant_id)
|
||||
|
||||
return MerchantDomainListResponse(
|
||||
domains=[
|
||||
MerchantDomainResponse(
|
||||
id=d.id,
|
||||
merchant_id=d.merchant_id,
|
||||
domain=d.domain,
|
||||
is_primary=d.is_primary,
|
||||
is_active=d.is_active,
|
||||
is_verified=d.is_verified,
|
||||
ssl_status=d.ssl_status,
|
||||
platform_id=d.platform_id,
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.get(
|
||||
"/domains/merchant/{domain_id}", response_model=MerchantDomainResponse
|
||||
)
|
||||
def get_merchant_domain_details(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific merchant domain (Admin only).
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
domain = merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||
|
||||
return MerchantDomainResponse(
|
||||
id=domain.id,
|
||||
merchant_id=domain.merchant_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
platform_id=domain.platform_id,
|
||||
verification_token=(
|
||||
domain.verification_token if not domain.is_verified else None
|
||||
),
|
||||
verified_at=domain.verified_at,
|
||||
ssl_verified_at=domain.ssl_verified_at,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.put(
|
||||
"/domains/merchant/{domain_id}", response_model=MerchantDomainResponse
|
||||
)
|
||||
def update_merchant_domain(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
domain_update: MerchantDomainUpdate = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update merchant domain settings (Admin only).
|
||||
|
||||
**Can update:**
|
||||
- `is_primary`: Set as primary domain for merchant
|
||||
- `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 = merchant_domain_service.update_domain(
|
||||
db=db, domain_id=domain_id, domain_update=domain_update
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return MerchantDomainResponse(
|
||||
id=domain.id,
|
||||
merchant_id=domain.merchant_id,
|
||||
domain=domain.domain,
|
||||
is_primary=domain.is_primary,
|
||||
is_active=domain.is_active,
|
||||
is_verified=domain.is_verified,
|
||||
ssl_status=domain.ssl_status,
|
||||
platform_id=domain.platform_id,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.delete(
|
||||
"/domains/merchant/{domain_id}",
|
||||
response_model=MerchantDomainDeletionResponse,
|
||||
)
|
||||
def delete_merchant_domain(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete a merchant domain (Admin only).
|
||||
|
||||
**Warning:** This is permanent and cannot be undone.
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
domain = merchant_domain_service.get_domain_by_id(db, domain_id)
|
||||
merchant_id = domain.merchant_id
|
||||
domain_name = domain.domain
|
||||
|
||||
message = merchant_domain_service.delete_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
return MerchantDomainDeletionResponse(
|
||||
message=message, domain=domain_name, merchant_id=merchant_id
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.post(
|
||||
"/domains/merchant/{domain_id}/verify",
|
||||
response_model=DomainVerificationResponse,
|
||||
)
|
||||
def verify_merchant_domain_ownership(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Verify merchant domain ownership via DNS TXT record (Admin only).
|
||||
|
||||
**Verification Process:**
|
||||
1. Queries DNS for TXT record: `_wizamart-verify.{domain}`
|
||||
2. Checks if verification token matches
|
||||
3. If found, marks domain as verified
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
- 400: Already verified, or verification failed
|
||||
- 502: DNS query failed
|
||||
"""
|
||||
domain, message = merchant_domain_service.verify_domain(db, domain_id)
|
||||
db.commit()
|
||||
|
||||
return DomainVerificationResponse(
|
||||
message=message,
|
||||
domain=domain.domain,
|
||||
verified_at=domain.verified_at,
|
||||
is_verified=domain.is_verified,
|
||||
)
|
||||
|
||||
|
||||
@admin_merchant_domains_router.get(
|
||||
"/domains/merchant/{domain_id}/verification-instructions",
|
||||
response_model=DomainVerificationInstructions,
|
||||
)
|
||||
def get_merchant_domain_verification_instructions(
|
||||
domain_id: int = Path(..., description="Domain ID", gt=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get DNS verification instructions for merchant domain (Admin only).
|
||||
|
||||
**Raises:**
|
||||
- 404: Domain not found
|
||||
"""
|
||||
instructions = merchant_domain_service.get_verification_instructions(
|
||||
db, domain_id
|
||||
)
|
||||
|
||||
return DomainVerificationInstructions(
|
||||
domain=instructions["domain"],
|
||||
verification_token=instructions["verification_token"],
|
||||
instructions=instructions["instructions"],
|
||||
txt_record=instructions["txt_record"],
|
||||
common_registrars=instructions["common_registrars"],
|
||||
)
|
||||
Reference in New Issue
Block a user