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:
2026-02-09 22:04:49 +01:00
parent c914e10cb8
commit 0984ff7d17
26 changed files with 2972 additions and 34 deletions

View File

@@ -0,0 +1,109 @@
# app/modules/tenancy/schemas/merchant_domain.py
"""
Pydantic schemas for Merchant Domain operations.
Schemas include:
- MerchantDomainCreate: For adding custom domains to merchants
- MerchantDomainUpdate: For updating domain settings
- MerchantDomainResponse: Standard domain response
- MerchantDomainListResponse: Paginated domain list
"""
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class MerchantDomainCreate(BaseModel):
"""Schema for adding a custom domain to a merchant."""
domain: str = Field(
...,
description="Custom domain (e.g., myloyaltyprogram.lu)",
min_length=3,
max_length=255,
)
is_primary: bool = Field(
default=True, description="Set as primary domain for the merchant"
)
platform_id: int | None = Field(None, description="Platform this domain belongs to")
@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://", "") # noqa: SEC-034
# 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", "cpanel", "webmail"]
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)
domain_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(domain_pattern, domain):
raise ValueError("Invalid domain format")
return domain
class MerchantDomainUpdate(BaseModel):
"""Schema for updating merchant domain settings."""
is_primary: bool | None = Field(None, description="Set as primary domain")
is_active: bool | None = Field(None, description="Activate or deactivate domain")
model_config = ConfigDict(from_attributes=True)
class MerchantDomainResponse(BaseModel):
"""Standard schema for merchant domain response."""
model_config = ConfigDict(from_attributes=True)
id: int
merchant_id: int
domain: str
is_primary: bool
is_active: bool
is_verified: bool
ssl_status: str
platform_id: int | None = None
verification_token: str | None = None
verified_at: datetime | None = None
ssl_verified_at: datetime | None = None
created_at: datetime
updated_at: datetime
class MerchantDomainListResponse(BaseModel):
"""Schema for paginated merchant domain list."""
domains: list[MerchantDomainResponse]
total: int
class MerchantDomainDeletionResponse(BaseModel):
"""Response after merchant domain deletion."""
message: str
domain: str
merchant_id: int