""" Tenant Member Repository Repository for tenant membership operations """ from typing import Optional, List, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, text, and_ from datetime import datetime, timedelta import structlog import json from .base import TenantBaseRepository from app.models.tenants import TenantMember from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError from shared.config.base import is_internal_service logger = structlog.get_logger() class TenantMemberRepository(TenantBaseRepository): """Repository for tenant member operations""" def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 300): # Member data changes more frequently, shorter cache time (5 minutes) super().__init__(model_class, session, cache_ttl) async def create_membership(self, membership_data: Dict[str, Any]) -> TenantMember: """Create a new tenant membership with validation""" try: # Validate membership data validation_result = self._validate_tenant_data( membership_data, ["tenant_id", "user_id", "role"] ) if not validation_result["is_valid"]: raise ValidationError(f"Invalid membership data: {validation_result['errors']}") # Check for existing membership existing_membership = await self.get_membership( membership_data["tenant_id"], membership_data["user_id"] ) if existing_membership and existing_membership.is_active: raise DuplicateRecordError(f"User is already an active member of this tenant") # Set default values if "is_active" not in membership_data: membership_data["is_active"] = True if "joined_at" not in membership_data: membership_data["joined_at"] = datetime.utcnow() # Set permissions based on role if "permissions" not in membership_data: membership_data["permissions"] = self._get_default_permissions( membership_data["role"] ) # If reactivating existing membership if existing_membership and not existing_membership.is_active: # Update existing membership update_data = { key: value for key, value in membership_data.items() if key not in ["tenant_id", "user_id"] } membership = await self.update(existing_membership.id, update_data) else: # Create new membership membership = await self.create(membership_data) logger.info("Tenant membership created", membership_id=membership.id, tenant_id=membership.tenant_id, user_id=membership.user_id, role=membership.role) return membership except (ValidationError, DuplicateRecordError): raise except Exception as e: logger.error("Failed to create membership", tenant_id=membership_data.get("tenant_id"), user_id=membership_data.get("user_id"), error=str(e)) raise DatabaseError(f"Failed to create membership: {str(e)}") async def get_membership(self, tenant_id: str, user_id: str) -> Optional[TenantMember]: """Get specific membership by tenant and user""" try: # Validate that user_id is a proper UUID format for actual users # Service names like 'inventory-service' should be handled differently import uuid try: uuid.UUID(user_id) is_valid_uuid = True except ValueError: is_valid_uuid = False # For internal service access, return None to indicate no user membership # Service access should be handled at the API layer if not is_valid_uuid: if is_internal_service(user_id): # This is a known internal service request, return None # Service access is granted at the API endpoint level logger.debug("Internal service detected in membership lookup", service=user_id, tenant_id=tenant_id) return None elif user_id == "unknown-service": # Special handling for 'unknown-service' which commonly occurs in demo sessions # This happens when service identification fails during demo operations logger.warning("Demo session service identification issue", service=user_id, tenant_id=tenant_id, message="Service not properly identified - likely demo session context") return None else: # This is an unknown service # Return None to prevent database errors, but log a warning logger.warning("Unknown service detected in membership lookup", service=user_id, tenant_id=tenant_id, message="Service not in internal services registry") return None memberships = await self.get_multi( filters={ "tenant_id": tenant_id, "user_id": user_id }, limit=1, order_by="created_at", order_desc=True ) return memberships[0] if memberships else None except Exception as e: logger.error("Failed to get membership", tenant_id=tenant_id, user_id=user_id, error=str(e)) raise DatabaseError(f"Failed to get membership: {str(e)}") async def get_tenant_members( self, tenant_id: str, active_only: bool = True, role: str = None, include_user_info: bool = False ) -> List[TenantMember]: """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 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, user_id: str, active_only: bool = True ) -> List[TenantMember]: """Get all tenants a user is a member of""" try: filters = {"user_id": user_id} if active_only: filters["is_active"] = True return await self.get_multi( filters=filters, order_by="joined_at", order_desc=True ) except Exception as e: logger.error("Failed to get user memberships", user_id=user_id, error=str(e)) raise DatabaseError(f"Failed to get memberships: {str(e)}") async def verify_user_access( self, user_id: str, tenant_id: str ) -> Dict[str, Any]: """Verify if user has access to tenant and return access details""" try: membership = await self.get_membership(tenant_id, user_id) if not membership or not membership.is_active: return { "has_access": False, "role": "none", "permissions": [] } # Parse permissions permissions = [] if membership.permissions: try: permissions = json.loads(membership.permissions) except json.JSONDecodeError: logger.warning("Invalid permissions JSON for membership", membership_id=membership.id) permissions = self._get_default_permissions(membership.role) return { "has_access": True, "role": membership.role, "permissions": permissions, "membership_id": str(membership.id), "joined_at": membership.joined_at.isoformat() if membership.joined_at else None } except Exception as e: logger.error("Failed to verify user access", user_id=user_id, tenant_id=tenant_id, error=str(e)) return { "has_access": False, "role": "none", "permissions": [] } async def update_member_role( self, tenant_id: str, user_id: str, new_role: str, updated_by: str = None ) -> Optional[TenantMember]: """Update member role and permissions""" try: valid_roles = ["owner", "admin", "member", "viewer"] if new_role not in valid_roles: raise ValidationError(f"Invalid role. Must be one of: {valid_roles}") membership = await self.get_membership(tenant_id, user_id) if not membership: raise ValidationError("Membership not found") # Get new permissions based on role new_permissions = self._get_default_permissions(new_role) updated_membership = await self.update(membership.id, { "role": new_role, "permissions": json.dumps(new_permissions) }) logger.info("Member role updated", membership_id=membership.id, tenant_id=tenant_id, user_id=user_id, old_role=membership.role, new_role=new_role, updated_by=updated_by) return updated_membership except ValidationError: raise except Exception as e: logger.error("Failed to update member role", tenant_id=tenant_id, user_id=user_id, new_role=new_role, error=str(e)) raise DatabaseError(f"Failed to update role: {str(e)}") async def deactivate_membership( self, tenant_id: str, user_id: str, deactivated_by: str = None ) -> Optional[TenantMember]: """Deactivate a membership (remove user from tenant)""" try: membership = await self.get_membership(tenant_id, user_id) if not membership: raise ValidationError("Membership not found") # Don't allow deactivating the owner if membership.role == "owner": raise ValidationError("Cannot deactivate the owner membership") updated_membership = await self.update(membership.id, { "is_active": False }) logger.info("Membership deactivated", membership_id=membership.id, tenant_id=tenant_id, user_id=user_id, deactivated_by=deactivated_by) return updated_membership except ValidationError: raise except Exception as e: logger.error("Failed to deactivate membership", tenant_id=tenant_id, user_id=user_id, error=str(e)) raise DatabaseError(f"Failed to deactivate membership: {str(e)}") async def reactivate_membership( self, tenant_id: str, user_id: str, reactivated_by: str = None ) -> Optional[TenantMember]: """Reactivate a deactivated membership""" try: membership = await self.get_membership(tenant_id, user_id) if not membership: raise ValidationError("Membership not found") updated_membership = await self.update(membership.id, { "is_active": True, "joined_at": datetime.utcnow() # Update join date }) logger.info("Membership reactivated", membership_id=membership.id, tenant_id=tenant_id, user_id=user_id, reactivated_by=reactivated_by) return updated_membership except ValidationError: raise except Exception as e: logger.error("Failed to reactivate membership", tenant_id=tenant_id, user_id=user_id, error=str(e)) raise DatabaseError(f"Failed to reactivate membership: {str(e)}") async def get_membership_statistics(self, tenant_id: str) -> Dict[str, Any]: """Get membership statistics for a tenant""" try: # Get counts by role role_query = text(""" SELECT role, COUNT(*) as count FROM tenant_members WHERE tenant_id = :tenant_id AND is_active = true GROUP BY role ORDER BY count DESC """) result = await self.session.execute(role_query, {"tenant_id": tenant_id}) members_by_role = {row.role: row.count for row in result.fetchall()} # Get basic counts total_members = await self.count(filters={"tenant_id": tenant_id}) active_members = await self.count(filters={ "tenant_id": tenant_id, "is_active": True }) # Get recent activity (members joined in last 30 days) thirty_days_ago = datetime.utcnow() - timedelta(days=30) recent_joins = len(await self.get_multi( filters={ "tenant_id": tenant_id, "is_active": True }, limit=1000 # High limit to get accurate count )) # Filter for recent joins (manual filtering since we can't use date range in filters easily) recent_members = 0 all_active_members = await self.get_tenant_members(tenant_id, active_only=True) for member in all_active_members: if member.joined_at and member.joined_at >= thirty_days_ago: recent_members += 1 return { "total_members": total_members, "active_members": active_members, "inactive_members": total_members - active_members, "members_by_role": members_by_role, "recent_joins_30d": recent_members } except Exception as e: logger.error("Failed to get membership statistics", tenant_id=tenant_id, error=str(e)) return { "total_members": 0, "active_members": 0, "inactive_members": 0, "members_by_role": {}, "recent_joins_30d": 0 } def _get_default_permissions(self, role: str) -> str: """Get default permissions JSON string for a role""" permission_map = { "owner": ["read", "write", "admin", "delete"], "admin": ["read", "write", "admin"], "member": ["read", "write"], "viewer": ["read"] } permissions = permission_map.get(role, ["read"]) return json.dumps(permissions) async def bulk_update_permissions( self, tenant_id: str, role_permissions: Dict[str, List[str]] ) -> int: """Bulk update permissions for all members of specific roles""" try: updated_count = 0 for role, permissions in role_permissions.items(): members = await self.get_tenant_members( tenant_id, active_only=True, role=role ) for member in members: await self.update(member.id, { "permissions": json.dumps(permissions) }) updated_count += 1 logger.info("Bulk updated member permissions", tenant_id=tenant_id, updated_count=updated_count, roles=list(role_permissions.keys())) return updated_count except Exception as e: logger.error("Failed to bulk update permissions", tenant_id=tenant_id, error=str(e)) raise DatabaseError(f"Bulk permission update failed: {str(e)}") async def cleanup_inactive_memberships(self, days_old: int = 180) -> int: """Clean up old inactive memberships""" try: cutoff_date = datetime.utcnow() - timedelta(days=days_old) query_text = """ DELETE FROM tenant_members WHERE is_active = false AND created_at < :cutoff_date """ result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date}) deleted_count = result.rowcount logger.info("Cleaned up inactive memberships", deleted_count=deleted_count, days_old=days_old) return deleted_count except Exception as e: logger.error("Failed to cleanup inactive memberships", error=str(e)) raise DatabaseError(f"Cleanup failed: {str(e)}")