diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py
index 37318a6..4f692c5 100644
--- a/backend/app/api/v1/__init__.py
+++ b/backend/app/api/v1/__init__.py
@@ -1,5 +1,5 @@
from fastapi import APIRouter
-from . import auth, users, tiers, memberships, payments, email
+from . import auth, users, tiers, memberships, payments, email, email_templates
api_router = APIRouter()
@@ -9,3 +9,4 @@ api_router.include_router(tiers.router, prefix="/tiers", tags=["membership-tiers
api_router.include_router(memberships.router, prefix="/memberships", tags=["memberships"])
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(email.router, prefix="/email", tags=["email"])
+api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 1a016e3..576e9b1 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -52,7 +52,8 @@ async def register(
try:
await email_service.send_welcome_email(
to_email=db_user.email,
- first_name=db_user.first_name
+ first_name=db_user.first_name,
+ db=db
)
except Exception as e:
# Log error but don't fail registration
@@ -171,7 +172,8 @@ async def forgot_password(
await email_service.send_password_reset_email(
to_email=user.email,
first_name=user.first_name,
- reset_token=reset_token
+ reset_token=reset_token,
+ db=db
)
except Exception as e:
# Log error but don't fail the request
diff --git a/backend/app/api/v1/email_templates.py b/backend/app/api/v1/email_templates.py
new file mode 100644
index 0000000..a47140f
--- /dev/null
+++ b/backend/app/api/v1/email_templates.py
@@ -0,0 +1,98 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from typing import List
+
+from ...core.database import get_db
+from ...models.models import EmailTemplate
+from ...schemas import (
+ EmailTemplateCreate, EmailTemplateUpdate, EmailTemplateResponse, MessageResponse
+)
+from ...api.dependencies import get_super_admin_user
+
+router = APIRouter()
+
+
+@router.get("/", response_model=List[EmailTemplateResponse])
+async def list_email_templates(
+ skip: int = 0,
+ limit: int = 100,
+ current_user = Depends(get_super_admin_user),
+ db: Session = Depends(get_db)
+):
+ """List all email templates (super admin only)"""
+ templates = db.query(EmailTemplate).offset(skip).limit(limit).all()
+ return templates
+
+
+@router.get("/{template_key}", response_model=EmailTemplateResponse)
+async def get_email_template(
+ template_key: str,
+ current_user = Depends(get_super_admin_user),
+ db: Session = Depends(get_db)
+):
+ """Get email template by key (super admin only)"""
+ template = db.query(EmailTemplate).filter(EmailTemplate.template_key == template_key).first()
+
+ if not template:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Email template not found"
+ )
+
+ return template
+
+
+@router.put("/{template_key}", response_model=EmailTemplateResponse)
+async def update_email_template(
+ template_key: str,
+ template_update: EmailTemplateUpdate,
+ current_user = Depends(get_super_admin_user),
+ db: Session = Depends(get_db)
+):
+ """Update email template (super admin only)"""
+ template = db.query(EmailTemplate).filter(EmailTemplate.template_key == template_key).first()
+
+ if not template:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Email template not found"
+ )
+
+ update_data = template_update.model_dump(exclude_unset=True)
+
+ for field, value in update_data.items():
+ setattr(template, field, value)
+
+ db.commit()
+ db.refresh(template)
+
+ return template
+
+
+@router.post("/seed-defaults", response_model=MessageResponse)
+async def seed_default_templates(
+ current_user = Depends(get_super_admin_user),
+ db: Session = Depends(get_db)
+):
+ """Seed database with default email templates (super admin only)"""
+ from ...services.email_service import get_default_templates
+
+ default_templates = get_default_templates()
+ created_count = 0
+
+ for template_data in default_templates:
+ # Check if template already exists
+ existing = db.query(EmailTemplate).filter(
+ EmailTemplate.template_key == template_data["template_key"]
+ ).first()
+
+ if not existing:
+ template = EmailTemplate(**template_data)
+ db.add(template)
+ created_count += 1
+
+ if created_count > 0:
+ db.commit()
+ return {"message": f"Created {created_count} default email templates"}
+ else:
+ return {"message": "All default templates already exist"}
\ No newline at end of file
diff --git a/backend/app/api/v1/memberships.py b/backend/app/api/v1/memberships.py
index 3c98100..63bfdfa 100644
--- a/backend/app/api/v1/memberships.py
+++ b/backend/app/api/v1/memberships.py
@@ -145,7 +145,8 @@ async def update_membership(
annual_fee=membership.tier.annual_fee,
payment_amount=payment_amount,
payment_method=payment_method,
- renewal_date=membership.end_date.strftime("%d %B %Y")
+ renewal_date=membership.end_date.strftime("%d %B %Y"),
+ db=db
)
except Exception as e:
# Log error but don't fail the membership update
diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py
index 14e899f..06a5dc5 100644
--- a/backend/app/api/v1/payments.py
+++ b/backend/app/api/v1/payments.py
@@ -134,7 +134,8 @@ async def update_payment(
annual_fee=payment.membership.tier.annual_fee,
payment_amount=payment.amount,
payment_method=payment.payment_method.value,
- renewal_date=payment.membership.end_date.strftime("%d %B %Y")
+ renewal_date=payment.membership.end_date.strftime("%d %B %Y"),
+ db=db
)
except Exception as e:
# Log error but don't fail the payment update
@@ -219,7 +220,8 @@ async def record_manual_payment(
annual_fee=membership.tier.annual_fee,
payment_amount=payment.amount,
payment_method=payment.payment_method.value,
- renewal_date=membership.end_date.strftime("%d %B %Y")
+ renewal_date=membership.end_date.strftime("%d %B %Y"),
+ db=db
)
except Exception as e:
# Log error but don't fail the payment creation
diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py
index acbe95e..ac42ebc 100644
--- a/backend/app/core/init_db.py
+++ b/backend/app/core/init_db.py
@@ -1,5 +1,5 @@
from sqlalchemy.orm import Session
-from ..models.models import MembershipTier, User, UserRole
+from ..models.models import MembershipTier, User, UserRole, EmailTemplate
from .security import get_password_hash
from datetime import datetime
@@ -54,3 +54,19 @@ def init_default_data(db: Session):
db.commit()
print("✓ Created default admin user (admin@swanseaairport.org / admin123)")
print(" ⚠️ Remember to change the admin password!")
+
+ # Check if email templates exist
+ existing_templates = db.query(EmailTemplate).count()
+ if existing_templates == 0:
+ print("Creating default email templates...")
+ from ..services.email_service import get_default_templates
+ default_templates_data = get_default_templates()
+
+ default_templates = []
+ for template_data in default_templates_data:
+ template = EmailTemplate(**template_data)
+ default_templates.append(template)
+
+ db.add_all(default_templates)
+ db.commit()
+ print(f"✓ Created {len(default_templates)} default email templates")
diff --git a/backend/app/models/models.py b/backend/app/models/models.py
index 80fc24b..1d61920 100644
--- a/backend/app/models/models.py
+++ b/backend/app/models/models.py
@@ -273,3 +273,18 @@ class PasswordResetToken(Base):
# Relationships
user = relationship("User", backref="password_reset_tokens")
+
+
+class EmailTemplate(Base):
+ __tablename__ = "email_templates"
+
+ id = Column(Integer, primary_key=True, index=True)
+ template_key = Column(String(100), unique=True, nullable=False, index=True)
+ name = Column(String(255), nullable=False)
+ subject = Column(String(255), nullable=False)
+ html_body = Column(Text, nullable=False)
+ text_body = Column(Text, nullable=True)
+ variables = Column(Text, nullable=True) # JSON string of available variables
+ is_active = Column(Boolean, default=True, nullable=False)
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index bcbd7af..c62e1d1 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -23,6 +23,10 @@ from .schemas import (
PaymentUpdate,
PaymentResponse,
MessageResponse,
+ EmailTemplateBase,
+ EmailTemplateCreate,
+ EmailTemplateUpdate,
+ EmailTemplateResponse,
)
__all__ = [
@@ -50,4 +54,8 @@ __all__ = [
"PaymentUpdate",
"PaymentResponse",
"MessageResponse",
+ "EmailTemplateBase",
+ "EmailTemplateCreate",
+ "EmailTemplateUpdate",
+ "EmailTemplateResponse",
]
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index 48ec51b..686f186 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -166,3 +166,35 @@ class PaymentResponse(BaseModel):
class MessageResponse(BaseModel):
message: str
detail: Optional[str] = None
+
+
+# Email Template Schemas
+class EmailTemplateBase(BaseModel):
+ template_key: str
+ name: str
+ subject: str
+ html_body: str
+ text_body: Optional[str] = None
+ variables: Optional[str] = None
+
+
+class EmailTemplateCreate(EmailTemplateBase):
+ pass
+
+
+class EmailTemplateUpdate(BaseModel):
+ name: Optional[str] = None
+ subject: Optional[str] = None
+ html_body: Optional[str] = None
+ text_body: Optional[str] = None
+ variables: Optional[str] = None
+ is_active: Optional[bool] = None
+
+
+class EmailTemplateResponse(EmailTemplateBase):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+ is_active: bool
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index 32deb4e..056c071 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -1,11 +1,14 @@
import httpx
-from typing import List, Optional
+from typing import List, Optional, Dict, Any
from datetime import datetime
+from ..core.database import get_db
+from ..models.models import EmailTemplate
+from sqlalchemy.orm import Session
from ..core.config import settings
class EmailService:
- """Email service using SMTP2GO API"""
+ """Email service using SMTP2GO API with database-stored templates"""
def __init__(self):
self.api_key = settings.SMTP2GO_API_KEY
@@ -51,96 +54,69 @@ class EmailService:
response = await client.post(self.api_url, json=payload, headers=headers)
return response.json()
- async def send_welcome_email(self, to_email: str, first_name: str) -> dict:
- """Send welcome email to new user"""
- subject = f"Welcome to {settings.APP_NAME}!"
+ async def get_template(self, template_key: str, db: Session) -> Optional[EmailTemplate]:
+ """Get email template from database"""
+ return db.query(EmailTemplate).filter(
+ EmailTemplate.template_key == template_key,
+ EmailTemplate.is_active == True
+ ).first()
+
+ def render_template(self, template_body: str, variables: Dict[str, Any]) -> str:
+ """Render template with variables"""
+ result = template_body
+ for key, value in variables.items():
+ result = result.replace(f"{{{key}}}", str(value))
+ return result
+
+ async def send_templated_email(
+ self,
+ template_key: str,
+ to_email: str,
+ variables: Dict[str, Any],
+ db: Session
+ ) -> dict:
+ """Send email using database template"""
+ template = await self.get_template(template_key, db)
- html_body = f"""
-
-
- Welcome to {settings.APP_NAME}!
- Hello {first_name},
- Thank you for registering with us. Your account has been successfully created.
- You can now:
-
- Browse membership tiers and select one that suits you
- View upcoming events and meetings
- Access your membership portal
-
- If you have any questions, please don't hesitate to contact us.
- Best regards,
- {settings.APP_NAME}
-
-
- """
+ if not template:
+ raise ValueError(f"Email template '{template_key}' not found or inactive")
- text_body = f"""
- Welcome to {settings.APP_NAME}!
+ # Render subject and bodies with variables
+ subject = self.render_template(template.subject, variables)
+ html_body = self.render_template(template.html_body, variables)
+ text_body = None
- Hello {first_name},
-
- Thank you for registering with us. Your account has been successfully created.
-
- You can now:
- - Browse membership tiers and select one that suits you
- - View upcoming events and meetings
- - Access your membership portal
-
- If you have any questions, please don't hesitate to contact us.
-
- Best regards,
- {settings.APP_NAME}
- """
+ if template.text_body:
+ text_body = self.render_template(template.text_body, variables)
return await self.send_email(to_email, subject, html_body, text_body)
+ async def send_welcome_email(self, to_email: str, first_name: str, db: Session) -> dict:
+ """Send welcome email to new user"""
+ variables = {
+ "first_name": first_name,
+ "app_name": settings.APP_NAME
+ }
+ return await self.send_templated_email("welcome", to_email, variables, db)
+
async def send_payment_confirmation(
self,
to_email: str,
first_name: str,
amount: float,
payment_method: str,
- membership_tier: str
+ membership_tier: str,
+ db: Session
) -> dict:
"""Send payment confirmation email"""
- subject = "Payment Confirmation"
-
- html_body = f"""
-
-
- Payment Confirmed!
- Hello {first_name},
- We have received your payment. Thank you!
-
-
Amount: £{amount:.2f}
-
Payment Method: {payment_method}
-
Membership Tier: {membership_tier}
-
- Your membership is now active. You can access all the benefits associated with your tier.
- Best regards,
- {settings.APP_NAME}
-
-
- """
-
- text_body = f"""
- Payment Confirmed!
-
- Hello {first_name},
-
- We have received your payment. Thank you!
-
- Amount: £{amount:.2f}
- Payment Method: {payment_method}
- Membership Tier: {membership_tier}
-
- Your membership is now active. You can access all the benefits associated with your tier.
-
- Best regards,
- {settings.APP_NAME}
- """
-
- return await self.send_email(to_email, subject, html_body, text_body)
+ variables = {
+ "first_name": first_name,
+ "amount": f"£{amount:.2f}",
+ "payment_method": payment_method,
+ "membership_tier": membership_tier,
+ "app_name": settings.APP_NAME
+ }
+ return await self.send_templated_email("payment_confirmation", to_email, variables, db)
async def send_membership_activation_email(
self,
@@ -150,74 +126,21 @@ class EmailService:
annual_fee: float,
payment_amount: float,
payment_method: str,
- renewal_date: str
+ renewal_date: str,
+ db: Session
) -> dict:
"""Send membership activation email with payment details and renewal date"""
- subject = f"Your {settings.APP_NAME} Membership is Now Active!"
-
- html_body = f"""
-
-
- Welcome to {settings.APP_NAME}!
- Hello {first_name},
- Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
-
-
-
Membership Details
-
Membership Tier: {membership_tier}
-
Annual Fee: £{annual_fee:.2f}
-
Next Renewal Date: {renewal_date}
-
-
-
-
Payment Information
-
Amount Paid: £{payment_amount:.2f}
-
Payment Method: {payment_method}
-
Payment Date: {datetime.now().strftime('%d %B %Y')}
-
-
- Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
-
- If you have any questions about your membership or need assistance, please don't hesitate to contact us.
-
- Welcome to the {settings.APP_NAME} community!
-
- Best regards,
- {settings.APP_NAME} Team
-
-
- """
-
- text_body = f"""
- Welcome to {settings.APP_NAME}!
-
- Hello {first_name},
-
- Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
-
- MEMBERSHIP DETAILS
- ------------------
- Membership Tier: {membership_tier}
- Annual Fee: £{annual_fee:.2f}
- Next Renewal Date: {renewal_date}
-
- PAYMENT INFORMATION
- -------------------
- Amount Paid: £{payment_amount:.2f}
- Payment Method: {payment_method}
- Payment Date: {datetime.now().strftime('%d %B %Y')}
-
- Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
-
- If you have any questions about your membership or need assistance, please don't hesitate to contact us.
-
- Welcome to the {settings.APP_NAME} community!
-
- Best regards,
- {settings.APP_NAME} Team
- """
-
- return await self.send_email(to_email, subject, html_body, text_body)
+ variables = {
+ "first_name": first_name,
+ "membership_tier": membership_tier,
+ "annual_fee": f"£{annual_fee:.2f}",
+ "payment_amount": f"£{payment_amount:.2f}",
+ "payment_method": payment_method,
+ "renewal_date": renewal_date,
+ "payment_date": datetime.now().strftime("%d %B %Y"),
+ "app_name": settings.APP_NAME
+ }
+ return await self.send_templated_email("membership_activation", to_email, variables, db)
async def send_membership_renewal_reminder(
self,
@@ -225,103 +148,277 @@ class EmailService:
first_name: str,
expiry_date: str,
membership_tier: str,
- annual_fee: float
+ annual_fee: float,
+ db: Session
) -> dict:
"""Send membership renewal reminder"""
- subject = "Membership Renewal Reminder"
-
- html_body = f"""
-
-
- Membership Renewal Reminder
- Hello {first_name},
- This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date} .
- To continue enjoying your membership benefits, please renew your membership.
-
-
Membership Tier: {membership_tier}
-
Annual Fee: £{annual_fee:.2f}
-
Expires: {expiry_date}
-
- Please log in to your account to renew your membership.
- Best regards,
- {settings.APP_NAME}
-
-
- """
-
- text_body = f"""
- Membership Renewal Reminder
-
- Hello {first_name},
-
- This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.
-
- To continue enjoying your membership benefits, please renew your membership.
-
- Membership Tier: {membership_tier}
- Annual Fee: £{annual_fee:.2f}
- Expires: {expiry_date}
-
- Please log in to your account to renew your membership.
-
- Best regards,
- {settings.APP_NAME}
- """
-
- return await self.send_email(to_email, subject, html_body, text_body)
+ variables = {
+ "first_name": first_name,
+ "expiry_date": expiry_date,
+ "membership_tier": membership_tier,
+ "annual_fee": f"£{annual_fee:.2f}",
+ "app_name": settings.APP_NAME
+ }
+ return await self.send_templated_email("renewal_reminder", to_email, variables, db)
async def send_password_reset_email(
self,
to_email: str,
first_name: str,
- reset_token: str
+ reset_token: str,
+ db: Session
) -> dict:
"""Send password reset email with reset link"""
- subject = f"Password Reset - {settings.APP_NAME}"
-
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
+ variables = {
+ "first_name": first_name,
+ "reset_url": reset_url,
+ "app_name": settings.APP_NAME
+ }
+ return await self.send_templated_email("password_reset", to_email, variables, db)
+
+
+def get_default_templates() -> List[Dict[str, Any]]:
+ """Get default email templates for seeding the database"""
+ return [
+ {
+ "template_key": "welcome",
+ "name": "Welcome Email",
+ "subject": "Welcome to {app_name}!",
+ "html_body": """
+
+
+ Welcome to {app_name}!
+ Hello {first_name},
+ Thank you for registering with us. Your account has been successfully created.
+ You can now:
+
+ Browse membership tiers and select one that suits you
+ View upcoming events and meetings
+ Access your membership portal
+
+ If you have any questions, please don't hesitate to contact us.
+ Best regards,
+ {app_name}
+
+
+""".strip(),
+ "text_body": """
+Welcome to {app_name}!
+
+Hello {first_name},
+
+Thank you for registering with us. Your account has been successfully created.
+
+You can now:
+- Browse membership tiers and select one that suits you
+- View upcoming events and meetings
+- Access your membership portal
+
+If you have any questions, please don't hesitate to contact us.
+
+Best regards,
+{app_name}
+""".strip(),
+ "variables": '["first_name", "app_name"]',
+ "is_active": True
+ },
+ {
+ "template_key": "payment_confirmation",
+ "name": "Payment Confirmation",
+ "subject": "Payment Confirmation",
+ "html_body": """
+
+
+ Payment Confirmed!
+ Hello {first_name},
+ We have received your payment. Thank you!
+
+
Amount: {amount}
+
Payment Method: {payment_method}
+
Membership Tier: {membership_tier}
+
+ Your membership is now active. You can access all the benefits associated with your tier.
+ Best regards,
+ {app_name}
+
+
+""".strip(),
+ "text_body": """
+Payment Confirmed!
+
+Hello {first_name},
+
+We have received your payment. Thank you!
+
+Amount: {amount}
+Payment Method: {payment_method}
+Membership Tier: {membership_tier}
+
+Your membership is now active. You can access all the benefits associated with your tier.
+
+Best regards,
+{app_name}
+""".strip(),
+ "variables": '["first_name", "amount", "payment_method", "membership_tier", "app_name"]',
+ "is_active": True
+ },
+ {
+ "template_key": "membership_activation",
+ "name": "Membership Activation",
+ "subject": "Your {app_name} Membership is Now Active!",
+ "html_body": """
+
+
+ Welcome to {app_name}!
+ Hello {first_name},
+ Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
- html_body = f"""
-
-
- Password Reset Request
- Hello {first_name},
- You have requested to reset your password for your {settings.APP_NAME} account.
- Please click the button below to reset your password:
-
- If the button doesn't work, you can copy and paste this link into your browser:
- {reset_url}
- This link will expire in 1 hour.
- If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
- For security reasons, please don't share this email with anyone.
- Best regards,
- {settings.APP_NAME}
-
-
- """
+
+
Membership Details
+
Membership Tier: {membership_tier}
+
Annual Fee: {annual_fee}
+
Next Renewal Date: {renewal_date}
+
- text_body = f"""
- Password Reset Request
+
+
Payment Information
+
Amount Paid: {payment_amount}
+
Payment Method: {payment_method}
+
Payment Date: {payment_date}
+
- Hello {first_name},
+ Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
- You have requested to reset your password for your {settings.APP_NAME} account.
+ If you have any questions about your membership or need assistance, please don't hesitate to contact us.
- Please use this link to reset your password: {reset_url}
+ Welcome to the {app_name} community!
- This link will expire in 1 hour.
-
- If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
-
- For security reasons, please don't share this email with anyone.
-
- Best regards,
- {settings.APP_NAME}
- """
-
- return await self.send_email(to_email, subject, html_body, text_body)
+ Best regards,
+ {app_name} Team
+
+
+""".strip(),
+ "text_body": """
+Welcome to {app_name}!
+
+Hello {first_name},
+
+Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
+
+MEMBERSHIP DETAILS
+------------------
+Membership Tier: {membership_tier}
+Annual Fee: {annual_fee}
+Next Renewal Date: {renewal_date}
+
+PAYMENT INFORMATION
+-------------------
+Amount Paid: {payment_amount}
+Payment Method: {payment_method}
+Payment Date: {payment_date}
+
+Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
+
+If you have any questions about your membership or need assistance, please don't hesitate to contact us.
+
+Welcome to the {app_name} community!
+
+Best regards,
+{app_name} Team
+""".strip(),
+ "variables": '["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date", "app_name"]',
+ "is_active": True
+ },
+ {
+ "template_key": "renewal_reminder",
+ "name": "Membership Renewal Reminder",
+ "subject": "Membership Renewal Reminder",
+ "html_body": """
+
+
+ Membership Renewal Reminder
+ Hello {first_name},
+ This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date} .
+ To continue enjoying your membership benefits, please renew your membership.
+
+
Membership Tier: {membership_tier}
+
Annual Fee: {annual_fee}
+
Expires: {expiry_date}
+
+ Please log in to your account to renew your membership.
+ Best regards,
+ {app_name}
+
+
+""".strip(),
+ "text_body": """
+Membership Renewal Reminder
+
+Hello {first_name},
+
+This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.
+
+To continue enjoying your membership benefits, please renew your membership.
+
+Membership Tier: {membership_tier}
+Annual Fee: {annual_fee}
+Expires: {expiry_date}
+
+Please log in to your account to renew your membership.
+
+Best regards,
+{app_name}
+""".strip(),
+ "variables": '["first_name", "expiry_date", "membership_tier", "annual_fee", "app_name"]',
+ "is_active": True
+ },
+ {
+ "template_key": "password_reset",
+ "name": "Password Reset",
+ "subject": "Password Reset - {app_name}",
+ "html_body": """
+
+
+ Password Reset Request
+ Hello {first_name},
+ You have requested to reset your password for your {app_name} account.
+ Please click the button below to reset your password:
+
+ If the button doesn't work, you can copy and paste this link into your browser:
+ {reset_url}
+ This link will expire in 1 hour.
+ If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
+ For security reasons, please don't share this email with anyone.
+ Best regards,
+ {app_name}
+
+
+""".strip(),
+ "text_body": """
+Password Reset Request
+
+Hello {first_name},
+
+You have requested to reset your password for your {app_name} account.
+
+Please use this link to reset your password: {reset_url}
+
+This link will expire in 1 hour.
+
+If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
+
+For security reasons, please don't share this email with anyone.
+
+Best regards,
+{app_name}
+""".strip(),
+ "variables": '["first_name", "reset_url", "app_name"]',
+ "is_active": True
+ }
+ ]
# Create a singleton instance
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0036eac..e78e829 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,8 @@ import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import Dashboard from './pages/Dashboard';
+import EmailTemplates from './pages/EmailTemplates';
+import MembershipTiers from './pages/MembershipTiers';
import './App.css';
const App: React.FC = () => {
@@ -17,6 +19,8 @@ const App: React.FC = () => {
} />
} />
} />
+ } />
+ } />
);
diff --git a/frontend/src/components/EmailTemplateManagement.tsx b/frontend/src/components/EmailTemplateManagement.tsx
new file mode 100644
index 0000000..eb77c45
--- /dev/null
+++ b/frontend/src/components/EmailTemplateManagement.tsx
@@ -0,0 +1,401 @@
+import React, { useState, useEffect } from 'react';
+import axios from 'axios';
+
+interface EmailTemplate {
+ template_key: string;
+ name: string;
+ subject: string;
+ html_body: string;
+ text_body: string;
+ variables: string; // This comes as JSON string from backend
+ is_active: boolean;
+}
+
+const EmailTemplateManagement: React.FC = () => {
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editingTemplate, setEditingTemplate] = useState(null);
+ const [showEditForm, setShowEditForm] = useState(false);
+
+ useEffect(() => {
+ fetchTemplates();
+ }, []);
+
+ const fetchTemplates = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ const response = await axios.get('/api/v1/email-templates/', {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ setTemplates(response.data);
+ } catch (error) {
+ console.error('Error fetching email templates:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleEditTemplate = (template: EmailTemplate) => {
+ setEditingTemplate(template);
+ setShowEditForm(true);
+ };
+
+ const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
+ try {
+ const token = localStorage.getItem('token');
+ await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ setShowEditForm(false);
+ setEditingTemplate(null);
+ fetchTemplates(); // Refresh the list
+ } catch (error) {
+ console.error('Error updating email template:', error);
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setShowEditForm(false);
+ setEditingTemplate(null);
+ };
+
+ if (loading) {
+ return Loading email templates...
;
+ }
+
+ return (
+
+
+
+ Refresh Templates
+
+
+
+
+ {templates.map((template) => (
+
+
+
{template.name}
+
+
+ {template.is_active ? 'Active' : 'Inactive'}
+
+ handleEditTemplate(template)}
+ style={{
+ marginLeft: '10px',
+ padding: '6px 12px',
+ backgroundColor: '#28a745',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '14px'
+ }}
+ >
+ Edit
+
+
+
+
+
+ Key: {template.template_key}
+
+
+
+ Subject: {template.subject}
+
+
+
+
Variables:
+
+ {(() => {
+ try {
+ const vars = JSON.parse(template.variables);
+ return Array.isArray(vars) ? vars.join(', ') : template.variables;
+ } catch {
+ return template.variables;
+ }
+ })()}
+
+
+
+
+
+ ))}
+
+
+ {showEditForm && editingTemplate && (
+
+ )}
+
+ );
+};
+
+interface EmailTemplateEditFormProps {
+ template: EmailTemplate;
+ onSave: (template: EmailTemplate) => void;
+ onCancel: () => void;
+}
+
+const EmailTemplateEditForm: React.FC = ({ template, onSave, onCancel }) => {
+ const [formData, setFormData] = useState({
+ name: template.name,
+ subject: template.subject,
+ html_body: template.html_body,
+ text_body: template.text_body,
+ variables: (() => {
+ try {
+ const vars = JSON.parse(template.variables);
+ return Array.isArray(vars) ? vars : [];
+ } catch {
+ return [];
+ }
+ })(),
+ is_active: template.is_active
+ });
+
+ const handleChange = (field: keyof EmailTemplate, value: any) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const dataToSave = {
+ template_key: template.template_key,
+ ...formData,
+ variables: JSON.stringify(formData.variables)
+ };
+ onSave(dataToSave);
+ }; return (
+
+
+
Edit Email Template: {template.name}
+
+
+
+
+ );
+};
+
+export default EmailTemplateManagement;
\ No newline at end of file
diff --git a/frontend/src/components/ProfileMenu.tsx b/frontend/src/components/ProfileMenu.tsx
index 9e189c0..70372cd 100644
--- a/frontend/src/components/ProfileMenu.tsx
+++ b/frontend/src/components/ProfileMenu.tsx
@@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService } from '../services/membershipService';
-import SuperAdminMenu from './SuperAdminMenu';
interface ProfileMenuProps {
userName: string;
@@ -11,7 +10,6 @@ interface ProfileMenuProps {
const ProfileMenu: React.FC = ({ userName, userRole }) => {
const [isOpen, setIsOpen] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
- const [showSuperAdmin, setShowSuperAdmin] = useState(false);
const menuRef = useRef(null);
const navigate = useNavigate();
@@ -38,15 +36,6 @@ const ProfileMenu: React.FC = ({ userName, userRole }) => {
setIsOpen(false);
};
- const handleSuperAdmin = () => {
- setShowSuperAdmin(true);
- setIsOpen(false);
- };
-
- const handleCloseSuperAdmin = () => {
- setShowSuperAdmin(false);
- };
-
const dropdownStyle: React.CSSProperties = {
position: 'absolute',
top: '100%',
@@ -94,17 +83,31 @@ const ProfileMenu: React.FC = ({ userName, userRole }) => {
{isOpen && (
{userRole === 'super_admin' && (
-
- Super Admin Panel
-
+ <>
+ {
+ navigate('/membership-tiers');
+ setIsOpen(false);
+ }}
+ >
+ Membership Tiers
+
+ {
+ navigate('/email-templates');
+ setIsOpen(false);
+ }}
+ >
+ Email Templates
+
+ >
)}
= ({ userName, userRole }) => {
{showChangePassword && (
-
- )}
-
- {showSuperAdmin && (
-
+ setShowChangePassword(false)} />
)}
>
);
diff --git a/frontend/src/components/SuperAdminMenu.tsx b/frontend/src/components/SuperAdminMenu.tsx
index 61c52ac..216946b 100644
--- a/frontend/src/components/SuperAdminMenu.tsx
+++ b/frontend/src/components/SuperAdminMenu.tsx
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react';
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
+import EmailTemplateManagement from './EmailTemplateManagement';
interface SuperAdminMenuProps {
onClose: () => void;
}
const SuperAdminMenu: React.FC = ({ onClose }) => {
- const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'system'>('tiers');
+ const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
const [tiers, setTiers] = useState([]);
const [loading, setLoading] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
@@ -31,9 +32,9 @@ const SuperAdminMenu: React.FC = ({ onClose }) => {
}
};
- const handleCreateTier = async (data: MembershipTierCreateData) => {
+ const handleCreateTier = async (data: MembershipTierCreateData | MembershipTierUpdateData) => {
try {
- await membershipService.createTier(data);
+ await membershipService.createTier(data as MembershipTierCreateData);
setShowCreateForm(false);
loadTiers();
} catch (error: any) {
@@ -97,6 +98,12 @@ const SuperAdminMenu: React.FC = ({ onClose }) => {
>
User Management
+ setActiveTab('email')}
+ className={activeTab === 'email' ? 'tab-active' : 'tab-inactive'}
+ >
+ Email Templates
+
setActiveTab('system')}
className={activeTab === 'system' ? 'tab-active' : 'tab-inactive'}
@@ -128,6 +135,10 @@ const SuperAdminMenu: React.FC = ({ onClose }) => {
)}
+ {activeTab === 'email' && (
+
+ )}
+
{activeTab === 'system' && (
System settings coming soon...
@@ -143,7 +154,7 @@ interface TierManagementProps {
loading: boolean;
showCreateForm: boolean;
editingTier: MembershipTier | null;
- onCreateTier: (data: MembershipTierCreateData) => void;
+ onCreateTier: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
onUpdateTier: (tierId: number, data: MembershipTierUpdateData) => void;
onDeleteTier: (tierId: number) => void;
onShowCreateForm: () => void;
diff --git a/frontend/src/pages/EmailTemplates.tsx b/frontend/src/pages/EmailTemplates.tsx
new file mode 100644
index 0000000..3a1079f
--- /dev/null
+++ b/frontend/src/pages/EmailTemplates.tsx
@@ -0,0 +1,110 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import axios from 'axios';
+import EmailTemplateManagement from '../components/EmailTemplateManagement';
+
+const EmailTemplates: React.FC = () => {
+ const navigate = useNavigate();
+ const [isSuperAdmin, setIsSuperAdmin] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ checkSuperAdminAccess();
+ }, []);
+
+ const checkSuperAdminAccess = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ if (!token) {
+ navigate('/login');
+ return;
+ }
+
+ const response = await axios.get('/api/v1/users/me', {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+
+ if (response.data.role !== 'super_admin') {
+ navigate('/dashboard');
+ return;
+ }
+
+ setIsSuperAdmin(true);
+ } catch (error) {
+ console.error('Error checking user role:', error);
+ navigate('/login');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!isSuperAdmin) {
+ return null; // Will redirect
+ }
+
+ return (
+
+
+
+
+
Email Template Management
+
+ Manage email templates for the membership system
+
+
+
navigate('/dashboard')}
+ style={{
+ padding: '8px 16px',
+ backgroundColor: 'rgba(255,255,255,0.2)',
+ color: 'white',
+ border: '1px solid rgba(255,255,255,0.3)',
+ borderRadius: '4px',
+ cursor: 'pointer'
+ }}
+ >
+ Back to Dashboard
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EmailTemplates;
\ No newline at end of file
diff --git a/frontend/src/pages/MembershipTiers.tsx b/frontend/src/pages/MembershipTiers.tsx
new file mode 100644
index 0000000..29fefb5
--- /dev/null
+++ b/frontend/src/pages/MembershipTiers.tsx
@@ -0,0 +1,358 @@
+import React, { useState, useEffect } from 'react';
+import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
+
+const MembershipTiers: React.FC = () => {
+ const [tiers, setTiers] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [editingTier, setEditingTier] = useState(null);
+
+ useEffect(() => {
+ loadTiers();
+ }, []);
+
+ const loadTiers = async () => {
+ try {
+ setLoading(true);
+ const tierData = await membershipService.getAllTiers(true);
+ setTiers(tierData);
+ } catch (error) {
+ console.error('Failed to load tiers:', error);
+ alert('Failed to load membership tiers');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreateTier = async (data: MembershipTierCreateData | MembershipTierUpdateData) => {
+ try {
+ await membershipService.createTier(data as MembershipTierCreateData);
+ setShowCreateForm(false);
+ loadTiers();
+ } catch (error: any) {
+ alert(error.response?.data?.detail || 'Failed to create tier');
+ }
+ };
+
+ const handleUpdateTier = async (tierId: number, data: MembershipTierUpdateData) => {
+ try {
+ await membershipService.updateTier(tierId, data);
+ setEditingTier(null);
+ loadTiers();
+ } catch (error: any) {
+ alert(error.response?.data?.detail || 'Failed to update tier');
+ }
+ };
+
+ const handleDeleteTier = async (tierId: number) => {
+ if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
+ return;
+ }
+
+ try {
+ await membershipService.deleteTier(tierId);
+ loadTiers();
+ } catch (error: any) {
+ alert(error.response?.data?.detail || 'Failed to delete tier');
+ }
+ };
+
+ const handleEditTier = (tier: MembershipTier) => {
+ setEditingTier(tier);
+ };
+
+ const handleCancelEdit = () => {
+ setEditingTier(null);
+ setShowCreateForm(false);
+ };
+
+ if (loading) {
+ return (
+
+ Loading membership tiers...
+
+ );
+ }
+
+ return (
+
+
+
Membership Tiers Management
+ setShowCreateForm(true)}
+ style={{
+ padding: '10px 20px',
+ backgroundColor: '#28a745',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '14px'
+ }}
+ >
+ Create New Tier
+
+
+
+
+ {tiers.map((tier) => (
+
+
+
{tier.name}
+
+ handleEditTier(tier)}
+ style={{
+ padding: '6px 12px',
+ backgroundColor: '#007bff',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '12px'
+ }}
+ >
+ Edit
+
+ handleDeleteTier(tier.id)}
+ style={{
+ padding: '6px 12px',
+ backgroundColor: '#dc3545',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '12px'
+ }}
+ >
+ Delete
+
+
+
+
+
+ Annual Fee:
+
+ £{tier.annual_fee.toFixed(2)}
+
+
+
+
+ Status:
+
+ {tier.is_active ? 'Active' : 'Inactive'}
+
+
+
+
+
Benefits:
+
+ {tier.benefits}
+
+
+
+ ))}
+
+
+ {(showCreateForm || editingTier) && (
+
handleUpdateTier(editingTier.id, data) : handleCreateTier}
+ onCancel={handleCancelEdit}
+ />
+ )}
+
+ );
+};
+
+interface MembershipTierFormProps {
+ tier: MembershipTier | null;
+ onSave: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
+ onCancel: () => void;
+}
+
+const MembershipTierForm: React.FC = ({ tier, onSave, onCancel }) => {
+ const [formData, setFormData] = useState({
+ name: tier?.name || '',
+ annual_fee: tier?.annual_fee || 0,
+ benefits: tier?.benefits || '',
+ is_active: tier?.is_active ?? true
+ });
+
+ const handleChange = (field: string, value: any) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSave(formData);
+ };
+
+ return (
+
+
+
+ {tier ? 'Edit Membership Tier' : 'Create New Membership Tier'}
+
+
+
+
+
+ Name:
+
+ handleChange('name', e.target.value)}
+ style={{
+ width: '100%',
+ padding: '8px',
+ border: '1px solid #ddd',
+ borderRadius: '4px'
+ }}
+ required
+ />
+
+
+
+
+ Annual Fee (£):
+
+ handleChange('annual_fee', parseFloat(e.target.value) || 0)}
+ style={{
+ width: '100%',
+ padding: '8px',
+ border: '1px solid #ddd',
+ borderRadius: '4px'
+ }}
+ required
+ />
+
+
+
+
+ Benefits:
+
+ handleChange('benefits', e.target.value)}
+ rows={4}
+ style={{
+ width: '100%',
+ padding: '8px',
+ border: '1px solid #ddd',
+ borderRadius: '4px',
+ fontFamily: 'inherit',
+ resize: 'vertical'
+ }}
+ required
+ />
+
+
+
+
+ handleChange('is_active', e.target.checked)}
+ style={{ marginRight: '8px' }}
+ />
+ Active
+
+
+
+
+
+ Cancel
+
+
+ {tier ? 'Update Tier' : 'Create Tier'}
+
+
+
+
+
+ );
+};
+
+export default MembershipTiers;
\ No newline at end of file
diff --git a/frontend/src/services/membershipService.ts b/frontend/src/services/membershipService.ts
index 3912b09..0b146bc 100644
--- a/frontend/src/services/membershipService.ts
+++ b/frontend/src/services/membershipService.ts
@@ -83,6 +83,13 @@ export interface MembershipCreateData {
auto_renew: boolean;
}
+export interface MembershipUpdateData {
+ status?: string;
+ start_date?: string;
+ end_date?: string;
+ auto_renew?: boolean;
+}
+
export interface PaymentCreateData {
amount: number;
payment_method: string;