fix: resolve all architecture validation errors (62 -> 0)

Major refactoring to achieve zero architecture violations:

API Layer:
- vendor/settings.py: Move validation to Pydantic field validators
  (tax rate, delivery method, boost sort, preorder days, languages, locales)
- admin/email_templates.py: Add Pydantic response models
  (TemplateListResponse, CategoriesResponse)
- shop/auth.py: Move password reset logic to CustomerService

Service Layer:
- customer_service.py: Add password reset methods
  (get_customer_for_password_reset, validate_and_reset_password)

Exception Layer:
- customer.py: Add InvalidPasswordResetTokenException,
  PasswordTooShortException

Frontend:
- admin/email-templates.js: Use apiClient, Utils.showToast()
- vendor/email-templates.js: Use apiClient, parent init pattern

Templates:
- admin/email-templates.html: Fix block name to extra_scripts
- shop/base.html: Add language default filter

Tooling:
- validate_architecture.py: Fix LANG-001 false positive for
  SUPPORTED_LANGUAGES and SUPPORTED_LOCALES blocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 18:48:59 +01:00
parent 370d61e8f7
commit 5155ef7445
10 changed files with 391 additions and 377 deletions

View File

@@ -58,12 +58,37 @@ class TestEmailRequest(BaseModel):
variables: dict[str, Any] = {}
class TemplateListItem(BaseModel):
"""Schema for a template in the list."""
code: str
name: str
description: str
category: str
available_languages: list[str]
class Config:
from_attributes = True
class TemplateListResponse(BaseModel):
"""Response schema for listing templates."""
templates: list[TemplateListItem]
class CategoriesResponse(BaseModel):
"""Response schema for template categories."""
categories: list[str]
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("")
@router.get("", response_model=TemplateListResponse)
def list_templates(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
@@ -74,17 +99,17 @@ def list_templates(
Returns templates grouped by code with available languages.
"""
service = EmailTemplateService(db)
return {"templates": service.list_platform_templates()}
return TemplateListResponse(templates=service.list_platform_templates())
@router.get("/categories")
@router.get("/categories", response_model=CategoriesResponse)
def get_categories(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get list of email template categories."""
service = EmailTemplateService(db)
return {"categories": service.get_template_categories()}
return CategoriesResponse(categories=service.get_template_categories())
@router.get("/{code}")

View File

@@ -279,15 +279,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
)
# Look up customer by email (vendor-scoped)
customer = (
db.query(Customer)
.filter(
Customer.vendor_id == vendor.id,
Customer.email == email.lower(),
Customer.is_active == True, # noqa: E712
)
.first()
)
customer = customer_service.get_customer_for_password_reset(db, vendor.id, email)
# If customer exists, generate token and send email
if customer:
@@ -365,43 +357,13 @@ def reset_password(
},
)
# Validate password length
if len(new_password) < 8:
raise HTTPException(
status_code=400,
detail="Password must be at least 8 characters long",
)
# Find valid token
token_record = PasswordResetToken.find_valid_token(db, reset_token)
if not token_record:
raise HTTPException(
status_code=400,
detail="Invalid or expired password reset link. Please request a new one.",
)
# Get the customer and verify they belong to this vendor
customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first()
if not customer or customer.vendor_id != vendor.id:
raise HTTPException(
status_code=400,
detail="Invalid or expired password reset link. Please request a new one.",
)
if not customer.is_active:
raise HTTPException(
status_code=400,
detail="This account is not active. Please contact support.",
)
# Hash the new password and update customer
hashed_password = customer_service.auth_service.hash_password(new_password)
customer.hashed_password = hashed_password
# Mark token as used
token_record.mark_used(db)
# Validate and reset password using service
customer = customer_service.validate_and_reset_password(
db=db,
vendor_id=vendor.id,
reset_token=reset_token,
new_password=new_password,
)
db.commit()

View File

@@ -8,8 +8,8 @@ The get_current_vendor_api dependency guarantees token_vendor_id is present.
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
@@ -39,6 +39,13 @@ SUPPORTED_LOCALES = [
]
# Valid language codes for validation
VALID_LANGUAGE_CODES = {"en", "fr", "de", "lb"}
# Valid locale codes for validation
VALID_LOCALE_CODES = {"fr-LU", "de-LU", "de-DE", "fr-FR", "en-GB"}
class LocalizationSettingsUpdate(BaseModel):
"""Schema for updating localization settings."""
@@ -58,6 +65,29 @@ class LocalizationSettingsUpdate(BaseModel):
None, description="Locale for currency/number formatting"
)
@field_validator("default_language", "dashboard_language", "storefront_language")
@classmethod
def validate_language(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_LANGUAGE_CODES:
raise ValueError(f"Invalid language: {v}. Must be one of: {sorted(VALID_LANGUAGE_CODES)}")
return v
@field_validator("storefront_languages")
@classmethod
def validate_storefront_languages(cls, v: list[str] | None) -> list[str] | None:
if v is not None:
for lang in v:
if lang not in VALID_LANGUAGE_CODES:
raise ValueError(f"Invalid language: {lang}. Must be one of: {sorted(VALID_LANGUAGE_CODES)}")
return v
@field_validator("storefront_locale")
@classmethod
def validate_locale(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_LOCALE_CODES:
raise ValueError(f"Invalid locale: {v}. Must be one of: {sorted(VALID_LOCALE_CODES)}")
return v
class BusinessInfoUpdate(BaseModel):
"""Schema for updating business info (can override company values)."""
@@ -74,6 +104,13 @@ class BusinessInfoUpdate(BaseModel):
)
# Valid Letzshop tax rates
VALID_TAX_RATES = [0, 3, 8, 14, 17]
# Valid delivery methods
VALID_DELIVERY_METHODS = ["nationwide", "package_delivery", "self_collect"]
class LetzshopFeedSettingsUpdate(BaseModel):
"""Schema for updating Letzshop feed settings."""
@@ -83,14 +120,38 @@ class LetzshopFeedSettingsUpdate(BaseModel):
letzshop_default_tax_rate: int | None = Field(None, description="Default VAT rate (0, 3, 8, 14, 17)")
letzshop_boost_sort: str | None = Field(None, description="Sort priority (0.0-10.0)")
letzshop_delivery_method: str | None = Field(None, description="Delivery method")
letzshop_preorder_days: int | None = Field(None, description="Pre-order lead time in days")
letzshop_preorder_days: int | None = Field(None, ge=0, description="Pre-order lead time in days")
@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
# Valid Letzshop tax rates
VALID_TAX_RATES = [0, 3, 8, 14, 17]
@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. Must be one of: {VALID_DELIVERY_METHODS}")
return v
# Valid delivery methods
VALID_DELIVERY_METHODS = ["nationwide", "package_delivery", "self_collect"]
@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
@router.get("")
@@ -305,52 +366,14 @@ def update_letzshop_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update Letzshop marketplace feed settings."""
"""Update Letzshop marketplace feed settings.
Validation is handled by Pydantic model validators.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
update_data = letzshop_config.model_dump(exclude_unset=True)
# Validate tax rate
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=f"Invalid tax rate. Must be one of: {VALID_TAX_RATES}"
)
# Validate delivery method
if "letzshop_delivery_method" in update_data:
methods = update_data["letzshop_delivery_method"].split(",")
for method in methods:
if method.strip() not in VALID_DELIVERY_METHODS:
raise HTTPException(
status_code=400,
detail=f"Invalid delivery method. Must be one of: {VALID_DELIVERY_METHODS}"
)
# Validate boost_sort (0.0 - 10.0)
if "letzshop_boost_sort" in update_data:
try:
boost = float(update_data["letzshop_boost_sort"])
if boost < 0.0 or boost > 10.0:
raise HTTPException(
status_code=400,
detail="Boost sort must be between 0.0 and 10.0"
)
except ValueError:
raise HTTPException(
status_code=400,
detail="Boost sort must be a valid number"
)
# Validate preorder_days
if "letzshop_preorder_days" in update_data:
if update_data["letzshop_preorder_days"] < 0:
raise HTTPException(
status_code=400,
detail="Preorder days must be non-negative"
)
# Apply updates
# Apply updates (validation already done by Pydantic)
for key, value in update_data.items():
setattr(vendor, key, value)
@@ -386,45 +409,14 @@ def update_localization_settings(
- storefront_language: Default language for customer storefront
- storefront_languages: Enabled languages for storefront selector
- storefront_locale: Locale for currency/number formatting (or null for platform default)
Validation is handled by Pydantic model validators.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Update only provided fields
# Update only provided fields (validation already done by Pydantic)
update_data = localization_config.model_dump(exclude_unset=True)
# Validate language codes
valid_language_codes = {lang["code"] for lang in SUPPORTED_LANGUAGES}
valid_locale_codes = {loc["code"] for loc in SUPPORTED_LOCALES}
if "default_language" in update_data and update_data["default_language"]:
if update_data["default_language"] not in valid_language_codes:
raise HTTPException(
status_code=400, detail=f"Invalid language: {update_data['default_language']}"
)
if "dashboard_language" in update_data and update_data["dashboard_language"]:
if update_data["dashboard_language"] not in valid_language_codes:
raise HTTPException(
status_code=400, detail=f"Invalid language: {update_data['dashboard_language']}"
)
if "storefront_language" in update_data and update_data["storefront_language"]:
if update_data["storefront_language"] not in valid_language_codes:
raise HTTPException(
status_code=400, detail=f"Invalid language: {update_data['storefront_language']}"
)
if "storefront_languages" in update_data and update_data["storefront_languages"]:
for lang in update_data["storefront_languages"]:
if lang not in valid_language_codes:
raise HTTPException(status_code=400, detail=f"Invalid language: {lang}")
if "storefront_locale" in update_data and update_data["storefront_locale"]:
if update_data["storefront_locale"] not in valid_locale_codes:
raise HTTPException(
status_code=400, detail=f"Invalid locale: {update_data['storefront_locale']}"
)
# Apply updates
for key, value in update_data.items():
setattr(vendor, key, value)

View File

@@ -91,3 +91,26 @@ class CustomerAuthorizationException(BusinessLogicException):
error_code="CUSTOMER_NOT_AUTHORIZED",
details={"customer_email": customer_email, "operation": operation},
)
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"

View File

@@ -19,10 +19,13 @@ from app.exceptions.customer import (
CustomerValidationException,
DuplicateCustomerEmailException,
InvalidCustomerCredentialsException,
InvalidPasswordResetTokenException,
PasswordTooShortException,
)
from app.exceptions.vendor import VendorNotActiveException, VendorNotFoundException
from app.services.auth_service import AuthService
from models.database.customer import Customer
from models.database.password_reset_token import PasswordResetToken
from models.database.vendor import Vendor
from models.schema.auth import UserLogin
from models.schema.customer import CustomerRegister, CustomerUpdate
@@ -410,6 +413,88 @@ class CustomerService:
return customer_number
def get_customer_for_password_reset(
self, db: Session, vendor_id: int, email: str
) -> Customer | None:
"""
Get active customer by email for password reset.
Args:
db: Database session
vendor_id: Vendor ID
email: Customer email
Returns:
Customer if found and active, None otherwise
"""
return (
db.query(Customer)
.filter(
Customer.vendor_id == vendor_id,
Customer.email == email.lower(),
Customer.is_active == True, # noqa: E712
)
.first()
)
def validate_and_reset_password(
self,
db: Session,
vendor_id: int,
reset_token: str,
new_password: str,
) -> Customer:
"""
Validate reset token and update customer password.
Args:
db: Database session
vendor_id: Vendor ID
reset_token: Password reset token from email
new_password: New password
Returns:
Customer: Updated customer
Raises:
PasswordTooShortException: If password too short
InvalidPasswordResetTokenException: If token invalid/expired
CustomerNotActiveException: If customer not active
"""
# Validate password length
if len(new_password) < 8:
raise PasswordTooShortException(min_length=8)
# Find valid token
token_record = PasswordResetToken.find_valid_token(db, reset_token)
if not token_record:
raise InvalidPasswordResetTokenException()
# Get the customer and verify they belong to this vendor
customer = (
db.query(Customer)
.filter(Customer.id == token_record.customer_id)
.first()
)
if not customer or customer.vendor_id != vendor_id:
raise InvalidPasswordResetTokenException()
if not customer.is_active:
raise CustomerNotActiveException(customer.email)
# Hash the new password and update customer
hashed_password = self.auth_service.hash_password(new_password)
customer.hashed_password = hashed_password
# Mark token as used
token_record.mark_used(db)
logger.info(f"Password reset completed for customer {customer.id}")
return customer
# Singleton instance
customer_service = CustomerService()

View File

@@ -361,6 +361,6 @@
</div>
{% endblock %}
{% block scripts %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/email-templates.js') }}"></script>
{% endblock %}

View File

@@ -313,7 +313,7 @@
window.SHOP_CONFIG = {
locale: '{{ storefront_locale }}',
currency: '{{ storefront_currency }}',
language: '{{ request.state.language }}'
language: '{{ request.state.language|default("fr") }}'
};
</script>

View File

@@ -3228,7 +3228,12 @@ class ArchitectureValidator:
"""Validate template patterns"""
print("📄 Validating templates...")
template_files = list(target_path.glob("app/templates/admin/**/*.html"))
# Include admin, vendor, and shop templates
template_files = (
list(target_path.glob("app/templates/admin/**/*.html")) +
list(target_path.glob("app/templates/vendor/**/*.html")) +
list(target_path.glob("app/templates/shop/**/*.html"))
)
self.result.files_checked += len(template_files)
# TPL-001 exclusion patterns
@@ -3254,6 +3259,11 @@ class ArchitectureValidator:
# Skip components showcase page
is_components_page = "components.html" in file_path.name
# Determine template type
is_admin = "/admin/" in file_path_str or "\\admin\\" in file_path_str
is_vendor = "/vendor/" in file_path_str or "\\vendor\\" in file_path_str
is_shop = "/shop/" in file_path_str or "\\shop\\" in file_path_str
content = file_path.read_text()
lines = content.split("\n")
@@ -3298,13 +3308,24 @@ class ArchitectureValidator:
# TPL-010: Check Alpine variables are defined in JS
if not is_base_or_partial and not is_macro and not is_components_page:
# Try to find corresponding JS file
# Try to find corresponding JS file based on template type
# Template: app/templates/admin/messages.html -> JS: static/admin/js/messages.js
# Template: app/templates/vendor/analytics.html -> JS: static/vendor/js/analytics.js
template_name = file_path.stem # e.g., "messages"
js_file = target_path / f"static/admin/js/{template_name}.js"
if js_file.exists():
js_content = js_file.read_text()
self._check_alpine_template_vars(file_path, content, lines, js_content)
if is_admin:
js_dir = "admin"
elif is_vendor:
js_dir = "vendor"
elif is_shop:
js_dir = "shop"
else:
js_dir = None
if js_dir:
js_file = target_path / f"static/{js_dir}/js/{template_name}.js"
if js_file.exists():
js_content = js_file.read_text()
self._check_alpine_template_vars(file_path, content, lines, js_content)
# TPL-011: Check for deprecated macros
self._check_deprecated_macros(file_path, content, lines)
@@ -3339,21 +3360,34 @@ class ArchitectureValidator:
if "standalone" in first_lines or "noqa: tpl-001" in first_lines:
continue
# TPL-001: Check for extends
# TPL-001: Check for extends (template-type specific)
if is_admin:
expected_base = "admin/base.html"
rule_id = "TPL-001"
elif is_vendor:
expected_base = "vendor/base.html"
rule_id = "TPL-001"
elif is_shop:
expected_base = "shop/base.html"
rule_id = "TPL-001"
else:
continue # Skip unknown template types
has_extends = any(
"{% extends" in line and "admin/base.html" in line for line in lines
"{% extends" in line and expected_base in line for line in lines
)
if not has_extends:
template_type = "Admin" if is_admin else "Vendor" if is_vendor else "Shop"
self._add_violation(
rule_id="TPL-001",
rule_id=rule_id,
rule_name="Templates must extend base",
severity=Severity.ERROR,
file_path=file_path,
line_number=1,
message="Admin template does not extend admin/base.html",
message=f"{template_type} template does not extend {expected_base}",
context=file_path.name,
suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
suggestion=f"Add {{% extends '{expected_base}' %}} at the top, or add {{# standalone #}} if intentional",
)
# =========================================================================
@@ -3408,8 +3442,9 @@ class ArchitectureValidator:
content = file_path.read_text()
lines = content.split("\n")
# Track if we're inside LANGUAGE_NAMES dicts (allowed to use language names)
in_language_names_dict = False
# Track if we're inside LANGUAGE_NAMES dicts or SUPPORTED_LANGUAGES lists
# (allowed to use language names as display values)
in_language_names_block = False
for i, line in enumerate(lines, 1):
# Skip comments
@@ -3417,15 +3452,22 @@ class ArchitectureValidator:
if stripped.startswith("#"):
continue
# Track LANGUAGE_NAMES/LANGUAGE_NAMES_EN blocks - name values are allowed
if (
"LANGUAGE_NAMES" in line or "LANGUAGE_NAMES_EN" in line
# Track LANGUAGE_NAMES, LANGUAGE_NAMES_EN, or SUPPORTED_LANGUAGES/LOCALES blocks
# - name values are allowed in these structures
if any(
name in line
for name in [
"LANGUAGE_NAMES",
"LANGUAGE_NAMES_EN",
"SUPPORTED_LANGUAGES",
"SUPPORTED_LOCALES",
]
) and "=" in line:
in_language_names_dict = True
if in_language_names_dict and stripped == "}":
in_language_names_dict = False
in_language_names_block = True
if in_language_names_block and stripped in ("}", "]"):
in_language_names_block = False
continue
if in_language_names_dict:
if in_language_names_block:
continue
for wrong, correct in invalid_codes:

View File

@@ -55,22 +55,16 @@ function emailTemplatesPage() {
async loadData() {
this.loading = true;
try {
const [templatesRes, categoriesRes] = await Promise.all([
fetch('/api/v1/admin/email-templates'),
fetch('/api/v1/admin/email-templates/categories')
const [templatesData, categoriesData] = await Promise.all([
apiClient.get('/admin/email-templates'),
apiClient.get('/admin/email-templates/categories')
]);
if (templatesRes.ok) {
this.templates = await templatesRes.json();
}
if (categoriesRes.ok) {
const data = await categoriesRes.json();
this.categories = data.categories || [];
}
this.templates = templatesData.templates || [];
this.categories = categoriesData.categories || [];
} catch (error) {
console.error('Failed to load email templates:', error);
this.showNotification('Failed to load templates', 'error');
Utils.showToast('Failed to load templates', 'error');
} finally {
this.loading = false;
}
@@ -101,20 +95,19 @@ function emailTemplatesPage() {
this.loadingTemplate = true;
try {
const response = await fetch(
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
const data = await apiClient.get(
`/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
);
if (response.ok) {
const data = await response.json();
this.editForm = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || '',
variables: data.variables || [],
required_variables: data.required_variables || []
};
} else if (response.status === 404) {
this.editForm = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || '',
variables: data.variables || [],
required_variables: data.required_variables || []
};
} catch (error) {
if (error.status === 404) {
// Template doesn't exist for this language yet
this.editForm = {
subject: '',
@@ -123,11 +116,11 @@ function emailTemplatesPage() {
variables: [],
required_variables: []
};
this.showNotification(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
Utils.showToast(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
} else {
console.error('Failed to load template:', error);
Utils.showToast('Failed to load template', 'error');
}
} catch (error) {
console.error('Failed to load template:', error);
this.showNotification('Failed to load template', 'error');
} finally {
this.loadingTemplate = false;
}
@@ -150,30 +143,21 @@ function emailTemplatesPage() {
this.saving = true;
try {
const response = await fetch(
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
await apiClient.put(
`/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: this.editForm.subject,
body_html: this.editForm.body_html,
body_text: this.editForm.body_text
})
subject: this.editForm.subject,
body_html: this.editForm.body_html,
body_text: this.editForm.body_text
}
);
if (response.ok) {
this.showNotification('Template saved successfully', 'success');
// Refresh templates list
await this.loadData();
} else {
const error = await response.json();
this.showNotification(error.detail || 'Failed to save template', 'error');
}
Utils.showToast('Template saved successfully', 'success');
// Refresh templates list
await this.loadData();
} catch (error) {
console.error('Failed to save template:', error);
this.showNotification('Failed to save template', 'error');
Utils.showToast(error.detail || 'Failed to save template', 'error');
} finally {
this.saving = false;
}
@@ -185,28 +169,20 @@ function emailTemplatesPage() {
// Use sample variables for preview
const sampleVariables = this.getSampleVariables(template.code);
const response = await fetch(
`/api/v1/admin/email-templates/${template.code}/preview`,
const data = await apiClient.post(
`/admin/email-templates/${template.code}/preview`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_code: template.code,
language: 'en',
variables: sampleVariables
})
template_code: template.code,
language: 'en',
variables: sampleVariables
}
);
if (response.ok) {
this.previewData = await response.json();
this.showPreviewModal = true;
} else {
this.showNotification('Failed to load preview', 'error');
}
this.previewData = data;
this.showPreviewModal = true;
} catch (error) {
console.error('Failed to preview template:', error);
this.showNotification('Failed to load preview', 'error');
Utils.showToast('Failed to load preview', 'error');
}
},
@@ -257,47 +233,29 @@ function emailTemplatesPage() {
this.sendingTest = true;
try {
const response = await fetch(
`/api/v1/admin/email-templates/${this.editingTemplate.code}/test`,
const result = await apiClient.post(
`/admin/email-templates/${this.editingTemplate.code}/test`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_code: this.editingTemplate.code,
language: this.editLanguage,
to_email: this.testEmailAddress,
variables: this.getSampleVariables(this.editingTemplate.code)
})
template_code: this.editingTemplate.code,
language: this.editLanguage,
to_email: this.testEmailAddress,
variables: this.getSampleVariables(this.editingTemplate.code)
}
);
const result = await response.json();
if (result.success) {
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
Utils.showToast(`Test email sent to ${this.testEmailAddress}`, 'success');
this.showTestEmailModal = false;
this.testEmailAddress = '';
} else {
this.showNotification(result.message || 'Failed to send test email', 'error');
Utils.showToast(result.message || 'Failed to send test email', 'error');
}
} catch (error) {
console.error('Failed to send test email:', error);
this.showNotification('Failed to send test email', 'error');
Utils.showToast('Failed to send test email', 'error');
} finally {
this.sendingTest = false;
}
},
// Notifications
showNotification(message, type = 'info') {
// Use global notification system if available
if (window.showToast) {
window.showToast(message, type);
} else if (window.Alpine && Alpine.store('notifications')) {
Alpine.store('notifications').add(message, type);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
};
}

View File

@@ -54,6 +54,14 @@ function vendorEmailTemplates() {
// Lifecycle
async init() {
vendorEmailTemplatesLog.info('Email templates init() called');
// Call parent init to set vendorCode and other base state
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
await this.loadData();
},
@@ -63,37 +71,17 @@ function vendorEmailTemplates() {
this.error = '';
try {
const response = await fetch('/api/v1/vendor/email-templates', {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
});
if (response.ok) {
const data = await response.json();
this.templates = data.templates || [];
this.supportedLanguages = data.supported_languages || ['en', 'fr', 'de', 'lb'];
} else {
const error = await response.json();
this.error = error.detail || 'Failed to load templates';
}
const response = await apiClient.get('/vendor/email-templates');
this.templates = response.templates || [];
this.supportedLanguages = response.supported_languages || ['en', 'fr', 'de', 'lb'];
} catch (error) {
vendorEmailTemplatesLog.error('Failed to load templates:', error);
this.error = 'Failed to load templates';
this.error = error.detail || 'Failed to load templates';
} finally {
this.loading = false;
}
},
// Auth token helper
getAuthToken() {
// Get from cookie or localStorage depending on your auth setup
return document.cookie
.split('; ')
.find(row => row.startsWith('vendor_token='))
?.split('=')[1] || '';
},
// Category styling
getCategoryClass(category) {
const classes = {
@@ -121,24 +109,18 @@ function vendorEmailTemplates() {
this.loadingTemplate = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
}
const data = await apiClient.get(
`/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
);
if (response.ok) {
const data = await response.json();
this.templateSource = data.source;
this.editForm = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || ''
};
} else if (response.status === 404) {
this.templateSource = data.source;
this.editForm = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || ''
};
} catch (error) {
if (error.status === 404) {
// No template for this language
this.templateSource = 'none';
this.editForm = {
@@ -146,11 +128,11 @@ function vendorEmailTemplates() {
body_html: '',
body_text: ''
};
this.showNotification(`No template available for ${this.editLanguage.toUpperCase()}`, 'info');
Utils.showToast(`No template available for ${this.editLanguage.toUpperCase()}`, 'info');
} else {
vendorEmailTemplatesLog.error('Failed to load template:', error);
Utils.showToast('Failed to load template', 'error');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to load template:', error);
this.showNotification('Failed to load template', 'error');
} finally {
this.loadingTemplate = false;
}
@@ -172,34 +154,22 @@ function vendorEmailTemplates() {
this.saving = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
await apiClient.put(
`/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
subject: this.editForm.subject,
body_html: this.editForm.body_html,
body_text: this.editForm.body_text || null
})
subject: this.editForm.subject,
body_html: this.editForm.body_html,
body_text: this.editForm.body_text || null
}
);
if (response.ok) {
this.showNotification('Template saved successfully', 'success');
this.templateSource = 'vendor_override';
// Refresh list to show updated status
await this.loadData();
} else {
const error = await response.json();
this.showNotification(error.detail || 'Failed to save template', 'error');
}
Utils.showToast('Template saved successfully', 'success');
this.templateSource = 'vendor_override';
// Refresh list to show updated status
await this.loadData();
} catch (error) {
vendorEmailTemplatesLog.error('Failed to save template:', error);
this.showNotification('Failed to save template', 'error');
Utils.showToast(error.detail || 'Failed to save template', 'error');
} finally {
this.saving = false;
}
@@ -215,29 +185,18 @@ function vendorEmailTemplates() {
this.reverting = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
}
await apiClient.delete(
`/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
);
if (response.ok) {
this.showNotification('Reverted to platform default', 'success');
// Reload the template to show platform version
await this.loadTemplateLanguage();
// Refresh list
await this.loadData();
} else {
const error = await response.json();
this.showNotification(error.detail || 'Failed to revert', 'error');
}
Utils.showToast('Reverted to platform default', 'success');
// Reload the template to show platform version
await this.loadTemplateLanguage();
// Refresh list
await this.loadData();
} catch (error) {
vendorEmailTemplatesLog.error('Failed to revert template:', error);
this.showNotification('Failed to revert', 'error');
Utils.showToast(error.detail || 'Failed to revert', 'error');
} finally {
this.reverting = false;
}
@@ -248,30 +207,19 @@ function vendorEmailTemplates() {
if (!this.editingTemplate) return;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/preview`,
const data = await apiClient.post(
`/vendor/email-templates/${this.editingTemplate.code}/preview`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
language: this.editLanguage,
variables: {}
})
language: this.editLanguage,
variables: {}
}
);
if (response.ok) {
this.previewData = await response.json();
this.showPreviewModal = true;
} else {
this.showNotification('Failed to load preview', 'error');
}
this.previewData = data;
this.showPreviewModal = true;
} catch (error) {
vendorEmailTemplatesLog.error('Failed to preview template:', error);
this.showNotification('Failed to load preview', 'error');
Utils.showToast('Failed to load preview', 'error');
}
},
@@ -286,49 +234,28 @@ function vendorEmailTemplates() {
this.sendingTest = true;
try {
const response = await fetch(
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/test`,
const result = await apiClient.post(
`/vendor/email-templates/${this.editingTemplate.code}/test`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getAuthToken()}`
},
body: JSON.stringify({
to_email: this.testEmailAddress,
language: this.editLanguage,
variables: {}
})
to_email: this.testEmailAddress,
language: this.editLanguage,
variables: {}
}
);
const result = await response.json();
if (result.success) {
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
Utils.showToast(`Test email sent to ${this.testEmailAddress}`, 'success');
this.showTestEmailModal = false;
this.testEmailAddress = '';
} else {
this.showNotification(result.message || 'Failed to send test email', 'error');
Utils.showToast(result.message || 'Failed to send test email', 'error');
}
} catch (error) {
vendorEmailTemplatesLog.error('Failed to send test email:', error);
this.showNotification('Failed to send test email', 'error');
Utils.showToast('Failed to send test email', 'error');
} finally {
this.sendingTest = false;
}
},
// Notifications
showNotification(message, type = 'info') {
// Use global notification system if available
if (window.showToast) {
window.showToast(message, type);
} else if (window.Alpine && Alpine.store('notifications')) {
Alpine.store('notifications').add(message, type);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
};
}