# Architecture Validation Fixes (January 2026)

## Overview

This document details the architecture validation fixes implemented to achieve **zero architecture errors** in the codebase. The fixes addressed violations across API endpoints, service layer, JavaScript files, and templates.

**Before:** 62 errors
**After:** 0 errors

---

## Summary of Changes

| Category | Files Changed | Violations Fixed |
|----------|---------------|------------------|
| API Layer | 3 files | 12 violations |
| Service Layer | 2 files | Business logic moved |
| Exceptions | 1 file | 2 new exceptions |
| JavaScript | 2 files | 8 violations |
| Templates | 2 files | 2 violations |
| Tooling | 1 file | 1 false positive fix |

---

## 1. API Layer Fixes

### 1.1 Store Settings API

**File:** `app/api/v1/store/settings.py`

**Problem:** 10 HTTPException raises directly in endpoint functions (API-003 violations).

**Solution:** Moved validation to Pydantic field validators.

#### Before (Inline Validation)
```python
@router.put("/letzshop")
def update_letzshop_settings(letzshop_config: LetzshopFeedSettingsUpdate, ...):
    update_data = letzshop_config.model_dump(exclude_unset=True)

    # Validate tax rate - VIOLATION: HTTPException in endpoint
    if "letzshop_default_tax_rate" in update_data:
        if update_data["letzshop_default_tax_rate"] not in VALID_TAX_RATES:
            raise HTTPException(status_code=400, detail="Invalid tax rate")
    # ... more inline validation
```

#### After (Pydantic Validators)
```python
# Constants moved above model
VALID_TAX_RATES = [0, 3, 8, 14, 17]
VALID_DELIVERY_METHODS = ["nationwide", "package_delivery", "self_collect"]

class LetzshopFeedSettingsUpdate(BaseModel):
    letzshop_default_tax_rate: int | None = Field(None)
    letzshop_boost_sort: str | None = Field(None)
    letzshop_delivery_method: str | None = Field(None)
    letzshop_preorder_days: int | None = Field(None, ge=0)  # Built-in validation

    @field_validator("letzshop_default_tax_rate")
    @classmethod
    def validate_tax_rate(cls, v: int | None) -> int | None:
        if v is not None and v not in VALID_TAX_RATES:
            raise ValueError(f"Invalid tax rate. Must be one of: {VALID_TAX_RATES}")
        return v

    @field_validator("letzshop_delivery_method")
    @classmethod
    def validate_delivery_method(cls, v: str | None) -> str | None:
        if v is not None:
            methods = v.split(",")
            for method in methods:
                if method.strip() not in VALID_DELIVERY_METHODS:
                    raise ValueError(f"Invalid delivery method")
        return v

    @field_validator("letzshop_boost_sort")
    @classmethod
    def validate_boost_sort(cls, v: str | None) -> str | None:
        if v is not None:
            try:
                boost = float(v)
                if boost < 0.0 or boost > 10.0:
                    raise ValueError("Boost sort must be between 0.0 and 10.0")
            except ValueError as e:
                if "could not convert" in str(e).lower():
                    raise ValueError("Boost sort must be a valid number")
                raise
        return v

# Endpoint now clean
@router.put("/letzshop")
def update_letzshop_settings(letzshop_config: LetzshopFeedSettingsUpdate, ...):
    """Validation is handled by Pydantic model validators."""
    store = store_service.get_store_by_id(db, current_user.token_store_id)
    update_data = letzshop_config.model_dump(exclude_unset=True)

    for key, value in update_data.items():
        setattr(store, key, value)
    # ...
```

**Same pattern applied to:**
- `LocalizationSettingsUpdate` - language and locale validation

---

### 1.2 Admin Email Templates API

**File:** `app/api/v1/admin/email_templates.py`

**Problem:** 2 endpoints returning raw dicts instead of Pydantic models (API-001 violations).

**Solution:** Added Pydantic response models.

#### Before
```python
@router.get("")
def list_templates(...):
    return {"templates": service.list_platform_templates()}

@router.get("/categories")
def get_categories(...):
    return {"categories": service.get_template_categories()}
```

#### After
```python
class TemplateListItem(BaseModel):
    code: str
    name: str
    description: str | None = None
    category: str
    languages: list[str]  # Matches service output
    is_platform_only: bool = False
    variables: list[str] = []

    class Config:
        from_attributes = True

class TemplateListResponse(BaseModel):
    templates: list[TemplateListItem]

class CategoriesResponse(BaseModel):
    categories: list[str]

@router.get("", response_model=TemplateListResponse)
def list_templates(...):
    return TemplateListResponse(templates=service.list_platform_templates())

@router.get("/categories", response_model=CategoriesResponse)
def get_categories(...):
    return CategoriesResponse(categories=service.get_template_categories())
```

---

### 1.3 Shop Auth API (Password Reset)

**File:** `app/api/v1/shop/auth.py`

**Problem:** Business logic in `forgot_password()` and `reset_password()` endpoints.

**Solution:** Moved business logic to `CustomerService`.

#### Before (Business Logic in Endpoint)
```python
@router.post("/auth/reset-password")
def reset_password(request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)):
    # Validate password length - BUSINESS LOGIC
    if len(new_password) < 8:
        raise HTTPException(status_code=400, detail="Password too short")

    # Find valid token - BUSINESS LOGIC
    token_record = PasswordResetToken.find_valid_token(db, reset_token)
    if not token_record:
        raise HTTPException(status_code=400, detail="Invalid token")

    # ... more business logic
```

#### After (Delegated to Service)
```python
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)):
    store = getattr(request.state, "store", None)
    if not store:
        raise StoreNotFoundException("context", identifier_type="subdomain")

    # All business logic in service
    customer = customer_service.validate_and_reset_password(
        db=db,
        store_id=store.id,
        reset_token=reset_token,
        new_password=new_password,
    )
    db.commit()

    return PasswordResetResponse(message="Password reset successfully.")
```

---

## 2. Service Layer Changes

### 2.1 CustomerService Enhancements

**File:** `app/services/customer_service.py`

Added two new methods for password reset:

```python
def get_customer_for_password_reset(
    self, db: Session, store_id: int, email: str
) -> Customer | None:
    """Get active customer by email for password reset."""
    return (
        db.query(Customer)
        .filter(
            Customer.store_id == store_id,
            Customer.email == email.lower(),
            Customer.is_active == True,
        )
        .first()
    )

def validate_and_reset_password(
    self,
    db: Session,
    store_id: int,
    reset_token: str,
    new_password: str,
) -> Customer:
    """Validate reset token and update customer password.

    Raises:
        PasswordTooShortException: If password too short
        InvalidPasswordResetTokenException: If token invalid/expired
        CustomerNotActiveException: If customer not active
    """
    if len(new_password) < 8:
        raise PasswordTooShortException(min_length=8)

    token_record = PasswordResetToken.find_valid_token(db, reset_token)
    if not token_record:
        raise InvalidPasswordResetTokenException()

    customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first()
    if not customer or customer.store_id != store_id:
        raise InvalidPasswordResetTokenException()

    if not customer.is_active:
        raise CustomerNotActiveException(customer.email)

    hashed_password = self.auth_service.hash_password(new_password)
    customer.hashed_password = hashed_password
    token_record.mark_used(db)

    return customer
```

---

## 3. Exception Layer

### 3.1 New Customer Exceptions

**File:** `app/exceptions/customer.py`

Added two new exceptions for password reset:

```python
class InvalidPasswordResetTokenException(ValidationException):
    """Raised when password reset token is invalid or expired."""

    def __init__(self):
        super().__init__(
            message="Invalid or expired password reset link. Please request a new one.",
            field="reset_token",
        )
        self.error_code = "INVALID_RESET_TOKEN"


class PasswordTooShortException(ValidationException):
    """Raised when password doesn't meet minimum length requirement."""

    def __init__(self, min_length: int = 8):
        super().__init__(
            message=f"Password must be at least {min_length} characters long",
            field="password",
            details={"min_length": min_length},
        )
        self.error_code = "PASSWORD_TOO_SHORT"
```

---

## 4. JavaScript Fixes

### 4.1 Admin Email Templates

**File:** `static/admin/js/email-templates.js`

**Problems:**
- Used raw `fetch()` instead of `apiClient`
- Used `showNotification()` instead of `Utils.showToast()`

#### Before
```javascript
async loadData() {
    this.loading = true;
    try {
        const response = await fetch('/api/v1/admin/email-templates');
        const data = await response.json();
        this.templates = data.templates || [];
    } catch (error) {
        showNotification('Failed to load templates', 'error');
    }
}
```

#### After
```javascript
async loadData() {
    this.loading = true;
    try {
        const [templatesData, categoriesData] = await Promise.all([
            apiClient.get('/admin/email-templates'),
            apiClient.get('/admin/email-templates/categories')
        ]);
        this.templates = templatesData.templates || [];
        this.categories = categoriesData.categories || [];
    } catch (error) {
        Utils.showToast('Failed to load templates', 'error');
    }
}
```

---

### 4.2 Store Email Templates

**File:** `static/store/js/email-templates.js`

**Problems:**
- Used raw `fetch()` instead of `apiClient`
- Missing parent init call pattern
- Used `showNotification()` instead of `Utils.showToast()`

#### Before
```javascript
async init() {
    await this.loadData();
}
```

#### After
```javascript
async init() {
    storeEmailTemplatesLog.info('Email templates init() called');

    // Call parent init to set storeCode and other base state
    const parentInit = data().init;
    if (parentInit) {
        await parentInit.call(this);
    }

    await this.loadData();
}
```

---

## 5. Template Fixes

### 5.1 Admin Email Templates Template

**File:** `app/templates/admin/email-templates.html`

**Problem:** Wrong block name (TPL-015 violation).

```html
<!-- Before -->
{% block scripts %}

<!-- After -->
{% block extra_scripts %}
```

---

### 5.2 Shop Base Template

**File:** `app/templates/shop/base.html`

**Problem:** `request.state.language` used without default (LANG-009 violation).

```html
<!-- Before -->
language: '{{ request.state.language }}'

<!-- After -->
language: '{{ request.state.language|default("fr") }}'
```

---

## 6. Architecture Validator Fix

### 6.1 LANG-001 False Positive

**File:** `scripts/validate/validate_architecture.py`

**Problem:** Validator flagged "English" as invalid language code in `SUPPORTED_LANGUAGES` dict (where it's a display name, not a code).

**Solution:** Extended pattern to skip `SUPPORTED_LANGUAGES` and `SUPPORTED_LOCALES` blocks.

```python
# Before: Only skipped LANGUAGE_NAMES blocks
if ("LANGUAGE_NAMES" in line or "LANGUAGE_NAMES_EN" in line) and "=" in line:
    in_language_names_dict = True

# After: Also skips SUPPORTED_LANGUAGES and SUPPORTED_LOCALES
if any(
    name in line
    for name in [
        "LANGUAGE_NAMES",
        "LANGUAGE_NAMES_EN",
        "SUPPORTED_LANGUAGES",
        "SUPPORTED_LOCALES",
    ]
) and "=" in line:
    in_language_names_block = True
if in_language_names_block and stripped in ("}", "]"):
    in_language_names_block = False
```

---

## Architecture Rules Reference

| Rule ID | Description | Fixed Files |
|---------|-------------|-------------|
| API-001 | Endpoint must use Pydantic models | admin/email_templates.py |
| API-003 | Endpoint must NOT contain business logic | store/settings.py, shop/auth.py |
| JS-001 | Must use apiClient for API calls | email-templates.js (admin & store) |
| JS-002 | Must use Utils.showToast() for notifications | email-templates.js (admin & store) |
| JS-003 | Must call parent init for Alpine components | store/email-templates.js |
| TPL-015 | Must use correct block names | admin/email-templates.html |
| LANG-009 | Must provide language default | shop/base.html |

---

## Testing

After these changes:

```bash
$ python scripts/validate/validate_architecture.py

Files checked: 439
Findings: 0 errors, 141 warnings, 2 info
```

The remaining warnings are acceptable patterns (mostly `db.commit()` in services which is allowed for complex operations).

---

## Commit Reference

```
commit 5155ef7
fix: resolve all architecture validation errors (62 -> 0)

10 files changed, 394 insertions(+), 380 deletions(-)
```
