# 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 {% block scripts %} {% 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 language: '{{ request.state.language }}' 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(-) ```