Improve the frontend and fix TODOs

This commit is contained in:
Urtzi Alfaro
2025-10-24 13:05:04 +02:00
parent 07c33fa578
commit 61376b7a9f
100 changed files with 8284 additions and 3419 deletions

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
from typing import List, Dict, Any
from uuid import UUID
from app.schemas.tenants import TenantMemberResponse
from app.schemas.tenants import TenantMemberResponse, AddMemberWithUserCreate
from app.services.tenant_service import EnhancedTenantService
from shared.auth.decorators import get_current_user_dep
from shared.routing.route_builder import RouteBuilder
@@ -29,6 +29,116 @@ def get_enhanced_tenant_service():
logger.error("Failed to create enhanced tenant service", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
@router.post(route_builder.build_base_route("{tenant_id}/members/with-user", include_tenant_prefix=False), response_model=TenantMemberResponse)
@track_endpoint_metrics("tenant_add_member_with_user_creation")
async def add_team_member_with_user_creation(
member_data: AddMemberWithUserCreate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""
Add a team member to tenant with optional user creation (pilot phase).
This endpoint supports two modes:
1. Adding an existing user: Set user_id and create_user=False
2. Creating a new user: Set create_user=True and provide email, full_name, password
In pilot phase, this allows owners to directly create users with passwords.
In production, this will be replaced with an invitation-based flow.
"""
try:
user_id_to_add = member_data.user_id
# If create_user is True, create the user first via auth service
if member_data.create_user:
logger.info(
"Creating new user before adding to tenant",
tenant_id=str(tenant_id),
email=member_data.email,
requested_by=current_user["user_id"]
)
# Call auth service to create user
from shared.clients.auth_client import AuthServiceClient
from app.core.config import settings
auth_client = AuthServiceClient(settings)
# Map tenant role to user role
# tenant roles: admin, member, viewer
# user roles: admin, manager, user
user_role_map = {
"admin": "admin",
"member": "manager",
"viewer": "user"
}
user_role = user_role_map.get(member_data.role, "user")
try:
user_create_data = {
"email": member_data.email,
"full_name": member_data.full_name,
"password": member_data.password,
"phone": member_data.phone,
"role": user_role,
"language": member_data.language or "es",
"timezone": member_data.timezone or "Europe/Madrid"
}
created_user = await auth_client.create_user_by_owner(user_create_data)
user_id_to_add = created_user.get("id")
logger.info(
"User created successfully",
user_id=user_id_to_add,
email=member_data.email,
tenant_id=str(tenant_id)
)
except Exception as auth_error:
logger.error(
"Failed to create user via auth service",
error=str(auth_error),
email=member_data.email
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create user account: {str(auth_error)}"
)
# Add the user (existing or newly created) to the tenant
result = await tenant_service.add_team_member(
str(tenant_id),
user_id_to_add,
member_data.role,
current_user["user_id"]
)
logger.info(
"Team member added successfully",
tenant_id=str(tenant_id),
user_id=user_id_to_add,
role=member_data.role,
user_was_created=member_data.create_user
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(
"Add team member with user creation failed",
tenant_id=str(tenant_id),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to add team member"
)
@router.post(route_builder.build_base_route("{tenant_id}/members", include_tenant_prefix=False), response_model=TenantMemberResponse)
@track_endpoint_metrics("tenant_add_member")
async def add_team_member(
@@ -38,7 +148,7 @@ async def add_team_member(
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Add a team member to tenant with enhanced validation and role management"""
"""Add an existing team member to tenant (legacy endpoint)"""
try:
result = await tenant_service.add_team_member(

View File

@@ -825,10 +825,53 @@ async def cancel_subscription(
"""Cancel subscription for a tenant"""
try:
# TODO: Add access control - verify user is owner/admin of tenant
# In a real implementation, you would need to retrieve the subscription ID from the database
# For now, this is a placeholder
subscription_id = "sub_test" # This would come from the database
# Verify user is owner/admin of tenant
user_id = current_user.get('user_id')
user_role = current_user.get('role', '').lower()
# Check if user is tenant owner or admin
from app.services.tenant_service import EnhancedTenantService
from shared.database.base import create_database_manager
tenant_service = EnhancedTenantService(create_database_manager())
# Verify tenant access and role
async with tenant_service.database_manager.get_session() as session:
await tenant_service._init_repositories(session)
# Get tenant member record
member = await tenant_service.member_repo.get_member_by_user_and_tenant(
str(user_id), str(tenant_id)
)
if not member:
logger.warning("User not member of tenant",
user_id=user_id,
tenant_id=str(tenant_id))
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: You are not a member of this tenant"
)
if member.role not in ['owner', 'admin']:
logger.warning("Insufficient permissions to cancel subscription",
user_id=user_id,
tenant_id=str(tenant_id),
role=member.role)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: Only owners and admins can cancel subscriptions"
)
# Get subscription ID from database
subscription = await tenant_service.subscription_repo.get_active_subscription(str(tenant_id))
if not subscription or not subscription.stripe_subscription_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription found for this tenant"
)
subscription_id = subscription.stripe_subscription_id
result = await payment_service.cancel_subscription(subscription_id)
@@ -856,10 +899,40 @@ async def get_invoices(
"""Get invoices for a tenant"""
try:
# TODO: Add access control - verify user has access to tenant
# In a real implementation, you would need to retrieve the customer ID from the database
# For now, this is a placeholder
customer_id = "cus_test" # This would come from the database
# Verify user has access to tenant
user_id = current_user.get('user_id')
from app.services.tenant_service import EnhancedTenantService
from shared.database.base import create_database_manager
tenant_service = EnhancedTenantService(create_database_manager())
async with tenant_service.database_manager.get_session() as session:
await tenant_service._init_repositories(session)
# Verify user is member of tenant
member = await tenant_service.member_repo.get_member_by_user_and_tenant(
str(user_id), str(tenant_id)
)
if not member:
logger.warning("User not member of tenant",
user_id=user_id,
tenant_id=str(tenant_id))
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: You do not have access to this tenant"
)
# Get subscription with customer ID
subscription = await tenant_service.subscription_repo.get_active_subscription(str(tenant_id))
if not subscription or not subscription.stripe_customer_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription found for this tenant"
)
customer_id = subscription.stripe_customer_id
invoices = await payment_service.get_invoices(customer_id)

View File

@@ -127,31 +127,135 @@ class TenantMemberRepository(TenantBaseRepository):
raise DatabaseError(f"Failed to get membership: {str(e)}")
async def get_tenant_members(
self,
tenant_id: str,
self,
tenant_id: str,
active_only: bool = True,
role: str = None
role: str = None,
include_user_info: bool = False
) -> List[TenantMember]:
"""Get all members of a tenant"""
"""Get all members of a tenant with optional user info enrichment"""
try:
filters = {"tenant_id": tenant_id}
if active_only:
filters["is_active"] = True
if role:
filters["role"] = role
return await self.get_multi(
members = await self.get_multi(
filters=filters,
order_by="joined_at",
order_desc=False
)
# If include_user_info is True, enrich with user data from auth service
if include_user_info and members:
members = await self._enrich_members_with_user_info(members)
return members
except Exception as e:
logger.error("Failed to get tenant members",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get members: {str(e)}")
async def _enrich_members_with_user_info(self, members: List[TenantMember]) -> List[TenantMember]:
"""Enrich member objects with user information from auth service using batch endpoint"""
try:
import httpx
import os
if not members:
return members
# Get unique user IDs
user_ids = list(set([str(member.user_id) for member in members]))
if not user_ids:
return members
# Fetch user data from auth service using batch endpoint
# Using internal service communication
auth_service_url = os.getenv('AUTH_SERVICE_URL', 'http://auth-service:8000')
user_data_map = {}
async with httpx.AsyncClient() as client:
try:
# Use batch endpoint for efficiency
response = await client.post(
f"{auth_service_url}/api/v1/auth/users/batch",
json={"user_ids": user_ids},
timeout=10.0,
headers={"X-Internal-Service": "tenant-service"}
)
if response.status_code == 200:
batch_result = response.json()
user_data_map = batch_result.get("users", {})
logger.info(
"Batch user fetch successful",
requested_count=len(user_ids),
found_count=batch_result.get("found_count", 0)
)
else:
logger.warning(
"Batch user fetch failed, falling back to individual calls",
status_code=response.status_code
)
# Fallback to individual calls if batch fails
for user_id in user_ids:
try:
response = await client.get(
f"{auth_service_url}/api/v1/auth/users/{user_id}",
timeout=5.0,
headers={"X-Internal-Service": "tenant-service"}
)
if response.status_code == 200:
user_data = response.json()
user_data_map[user_id] = user_data
except Exception as e:
logger.warning(f"Failed to fetch user data for {user_id}", error=str(e))
continue
except Exception as e:
logger.warning("Batch user fetch failed, falling back to individual calls", error=str(e))
# Fallback to individual calls
for user_id in user_ids:
try:
response = await client.get(
f"{auth_service_url}/api/v1/auth/users/{user_id}",
timeout=5.0,
headers={"X-Internal-Service": "tenant-service"}
)
if response.status_code == 200:
user_data = response.json()
user_data_map[user_id] = user_data
except Exception as e:
logger.warning(f"Failed to fetch user data for {user_id}", error=str(e))
continue
# Enrich members with user data
for member in members:
user_id_str = str(member.user_id)
if user_id_str in user_data_map and user_data_map[user_id_str] is not None:
user_data = user_data_map[user_id_str]
# Add user fields as attributes to the member object
member.user_email = user_data.get("email")
member.user_full_name = user_data.get("full_name")
member.user = user_data # Store full user object for compatibility
else:
# Set defaults for missing users
member.user_email = None
member.user_full_name = "Unknown User"
member.user = None
return members
except Exception as e:
logger.warning("Failed to enrich members with user info", error=str(e))
# Return members without enrichment if it fails
return members
async def get_user_memberships(
self,

View File

@@ -3,7 +3,7 @@
Tenant schemas - FIXED VERSION
"""
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, field_validator, ValidationInfo
from typing import Optional, List, Dict, Any
from datetime import datetime
from uuid import UUID
@@ -20,31 +20,34 @@ class BakeryRegistration(BaseModel):
business_model: Optional[str] = Field(default="individual_bakery")
coupon_code: Optional[str] = Field(None, max_length=50, description="Promotional coupon code")
@validator('phone')
@field_validator('phone')
@classmethod
def validate_spanish_phone(cls, v):
"""Validate Spanish phone number"""
# Remove spaces and common separators
phone = re.sub(r'[\s\-\(\)]', '', v)
# Spanish mobile: +34 6/7/8/9 + 8 digits
# Spanish landline: +34 9 + 8 digits
patterns = [
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
r'^(\+34|0034|34)?9\d{8}$', # Landline
]
if not any(re.match(pattern, phone) for pattern in patterns):
raise ValueError('Invalid Spanish phone number')
return v
@validator('business_type')
@field_validator('business_type')
@classmethod
def validate_business_type(cls, v):
valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant']
if v not in valid_types:
raise ValueError(f'Business type must be one of: {valid_types}')
return v
@validator('business_model')
@field_validator('business_model')
@classmethod
def validate_business_model(cls, v):
if v is None:
return v
@@ -72,7 +75,8 @@ class TenantResponse(BaseModel):
created_at: datetime
# ✅ FIX: Add custom validator to convert UUID to string
@validator('id', 'owner_id', pre=True)
@field_validator('id', 'owner_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""
if isinstance(v, UUID):
@@ -89,21 +93,26 @@ class TenantAccessResponse(BaseModel):
permissions: List[str]
class TenantMemberResponse(BaseModel):
"""Tenant member response - FIXED VERSION"""
"""Tenant member response - FIXED VERSION with enriched user data"""
id: str
user_id: str
role: str
is_active: bool
joined_at: Optional[datetime]
# Enriched user fields (populated via service layer)
user_email: Optional[str] = None
user_full_name: Optional[str] = None
user: Optional[Dict[str, Any]] = None # Full user object for compatibility
# ✅ FIX: Add custom validator to convert UUID to string
@validator('id', 'user_id', pre=True)
@field_validator('id', 'user_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""
if isinstance(v, UUID):
return str(v)
return v
class Config:
from_attributes = True
@@ -135,6 +144,42 @@ class TenantMemberUpdate(BaseModel):
role: Optional[str] = Field(None, pattern=r'^(owner|admin|member|viewer)$')
is_active: Optional[bool] = None
class AddMemberWithUserCreate(BaseModel):
"""Schema for adding member with optional user creation (pilot phase)"""
# For existing users
user_id: Optional[str] = Field(None, description="ID of existing user to add")
# For new user creation
create_user: bool = Field(False, description="Whether to create a new user")
email: Optional[str] = Field(None, description="Email for new user (if create_user=True)")
full_name: Optional[str] = Field(None, min_length=2, max_length=100, description="Full name for new user")
password: Optional[str] = Field(None, min_length=8, max_length=128, description="Password for new user")
phone: Optional[str] = Field(None, description="Phone number for new user")
language: Optional[str] = Field("es", pattern="^(es|en|eu)$", description="Preferred language")
timezone: Optional[str] = Field("Europe/Madrid", description="User timezone")
# Common fields
role: str = Field(..., pattern=r'^(admin|member|viewer)$', description="Role in the tenant")
@field_validator('email', 'full_name', 'password')
@classmethod
def validate_user_creation_fields(cls, v, info: ValidationInfo):
"""Validate that required fields are present when creating a user"""
if info.data.get('create_user') and info.field_name in ['email', 'full_name', 'password']:
if not v:
raise ValueError(f"{info.field_name} is required when create_user is True")
return v
@field_validator('user_id')
@classmethod
def validate_user_id_or_create(cls, v, info: ValidationInfo):
"""Ensure either user_id or create_user is provided"""
if not v and not info.data.get('create_user'):
raise ValueError("Either user_id or create_user must be provided")
if v and info.data.get('create_user'):
raise ValueError("Cannot specify both user_id and create_user")
return v
class TenantSubscriptionUpdate(BaseModel):
"""Schema for updating tenant subscription"""
plan: str = Field(..., pattern=r'^(basic|professional|enterprise)$')
@@ -151,7 +196,8 @@ class TenantStatsResponse(BaseModel):
subscription_plan: str
subscription_status: str
@validator('tenant_id', pre=True)
@field_validator('tenant_id', mode='before')
@classmethod
def convert_uuid_to_string(cls, v):
"""Convert UUID objects to strings for JSON serialization"""
if isinstance(v, UUID):

View File

@@ -98,9 +98,11 @@ class SubscriptionLimitService:
if subscription.max_locations == -1:
return {"can_add": True, "reason": "Unlimited locations allowed"}
# Count current locations (this would need to be implemented based on your location model)
# For now, we'll assume 1 location per tenant as default
current_locations = 1 # TODO: Implement actual location count
# Count current locations
# Currently, each tenant has 1 location (their primary bakery location)
# This is stored in tenant.address, tenant.city, tenant.postal_code
# If multi-location support is added in the future, this would query a locations table
current_locations = 1 # Each tenant has one primary location
can_add = current_locations < subscription.max_locations
return {
@@ -130,11 +132,10 @@ class SubscriptionLimitService:
# Check if unlimited products (-1)
if subscription.max_products == -1:
return {"can_add": True, "reason": "Unlimited products allowed"}
# Count current products (this would need to be implemented based on your product model)
# For now, we'll return a placeholder
current_products = 0 # TODO: Implement actual product count
# Count current products from inventory service
current_products = await self._get_ingredient_count(tenant_id)
can_add = current_products < subscription.max_products
return {
"can_add": can_add,
@@ -358,7 +359,7 @@ class SubscriptionLimitService:
# Get current usage - Team & Organization
members = await self.member_repo.get_tenant_members(tenant_id, active_only=True)
current_users = len(members)
current_locations = 1 # TODO: Implement actual location count from locations service
current_locations = 1 # Each tenant has one primary location
# Get current usage - Products & Inventory
current_products = await self._get_ingredient_count(tenant_id)

View File

@@ -427,24 +427,24 @@ class EnhancedTenantService:
)
async def get_team_members(
self,
tenant_id: str,
self,
tenant_id: str,
user_id: str,
active_only: bool = True
) -> List[TenantMemberResponse]:
"""Get all team members for a tenant"""
"""Get all team members for a tenant with enriched user information"""
try:
async with self.database_manager.get_session() as session:
# Initialize repositories with session
await self._init_repositories(session)
members = await self.member_repo.get_tenant_members(
tenant_id, active_only=active_only
tenant_id, active_only=active_only, include_user_info=True
)
return [TenantMemberResponse.from_orm(member) for member in members]
except HTTPException:
raise
except Exception as e: