File size: 6,092 Bytes
74de430
212bf52
67bed24
74de430
c591eda
212bf52
d9b075a
74de430
 
212bf52
 
 
67bed24
212bf52
67bed24
 
 
 
13ca341
67bed24
 
e36ac68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212bf52
 
 
67bed24
c591eda
 
212bf52
67bed24
 
 
212bf52
67bed24
 
 
 
 
 
 
 
212bf52
dd750b7
 
212bf52
67bed24
212bf52
 
 
 
 
67bed24
212bf52
 
 
 
 
 
 
67bed24
 
212bf52
 
67bed24
8169ef9
212bf52
d9b075a
 
 
 
ae9649e
 
 
 
 
 
d9b075a
 
 
212bf52
67bed24
212bf52
 
 
 
67bed24
340c4b4
 
 
67bed24
340c4b4
67bed24
 
340c4b4
 
 
 
67bed24
340c4b4
 
 
 
212bf52
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
ο»Ώ"""
User Model - Maps to existing Supabase 'users' table
Matches schema.sql exactly - field service management platform
"""
from sqlalchemy import Column, String, Boolean, Text, DateTime, Float, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import BaseModel


class User(BaseModel):
    """
    User model - Maps EXACTLY to users table in docs/schema/schema.sql
    
    Users are employees of either:
    - Clients (telecom operators who need field work done)
    - Contractors (companies that execute field work)
    - Platform admins (no org link)
    
    Authentication: Supabase Auth (auth.users table)
    This table stores business profile and authorization data
    
    USER LIFECYCLE STATE MACHINE:
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Status Flow:                                                     β”‚
    β”‚                                                                  β”‚
    β”‚ invited β†’ pending_setup β†’ active β†’ suspended                    β”‚
    β”‚    ↓           ↓            ↓          ↓                         β”‚
    β”‚  (invited) (setup req) (operational) (disabled)                β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    
    Status Definitions:
    - invited: User invited but hasn't accepted/registered yet
      (Used in invitation flow only, before user_invitations.accepted_at)
    
    - pending_setup: User accepted invitation but profile incomplete
      (Reserved for future: require certain fields before full access)
    
    - active: Fully operational user with complete access
      (Default after invitation acceptance, primary operational state)
    
    - suspended: Account temporarily disabled (is_active=False)
      (Can be reactivated by admin, preserves all data)
    
    Note: is_active field controls actual access:
    - active/pending_setup β†’ is_active=True (can login)
    - suspended β†’ is_active=False (blocked at API level)
    - invited β†’ varies (typically False until acceptance)
    """
    __tablename__ = "users"
    
    # Organization Links - User belongs to ONE organization (or is platform_admin)
    client_id = Column(UUID(as_uuid=True), ForeignKey('clients.id', ondelete='SET NULL'), nullable=True)
    contractor_id = Column(UUID(as_uuid=True), ForeignKey('contractors.id', ondelete='SET NULL'), nullable=True)
    
    # Role & Status
    role = Column(String(50), nullable=False)  # app_role ENUM in DB
    status = Column(String(50), default='invited')  # user_status ENUM in DB
    
    # Profile & Identity
    name = Column(String(255), nullable=False)  # Full name (NOT first_name/last_name)
    phone = Column(String(50), nullable=True)
    phone_alternate = Column(String(50), nullable=True)
    email = Column(String(255), nullable=True)
    id_number = Column(String(255), nullable=True)
    display_name = Column(String(255), nullable=True)
    is_active = Column(Boolean, default=True)
    
    # Note: invited_at and activated_at removed - tracked in user_invitations table
    # See migration: 14_cleanup_users_table.sql
    
    # Health & Safety (for field agents)
    health_info = Column(JSONB, default={})
    emergency_contact_name = Column(String(255), nullable=True)
    emergency_contact_phone = Column(String(50), nullable=True)
    ppe_sizes = Column(JSONB, default={})
    
    # Current Location (for field agents - updated via mobile app)
    current_location_name = Column(String(255), nullable=True)
    current_country = Column(String(100), default='Kenya')
    current_region = Column(String(255), nullable=True)
    current_city = Column(String(255), nullable=True)
    current_address_line1 = Column(String(500), nullable=True)
    current_address_line2 = Column(String(500), nullable=True)
    current_maps_link = Column(String(1000), nullable=True)
    current_latitude = Column(Float, nullable=True)  # DOUBLE PRECISION in DB
    current_longitude = Column(Float, nullable=True)  # DOUBLE PRECISION in DB
    current_location_updated_at = Column(DateTime(timezone=True), nullable=True)
    
    # Additional Metadata
    additional_metadata = Column(JSONB, default={})
    
    # Relationships - Using backref for most, explicit back_populates where needed
    # Note: Most relationships use backref in the referring model (one-way is sufficient)
    # Only add back_populates when bidirectional traversal is explicitly needed
    
    # Project team memberships (bidirectional)
    project_memberships = relationship("ProjectTeam", foreign_keys="[ProjectTeam.user_id]", lazy="dynamic")
    
    # User preferences (one-to-one)
    preferences = relationship("UserPreference", uselist=False, backref="user")
    
    # Sales orders where this user is the agent (explicit back_populates)
    sales_orders_as_agent = relationship("SalesOrder", foreign_keys="[SalesOrder.sales_agent_id]", back_populates="sales_agent", lazy="dynamic")
    
    def __repr__(self):
        return f"<User(email='{self.email}', name='{self.name}', role='{self.role}')>"
    
    @property
    def full_name(self) -> str:
        """Get user's full name"""
        return self.display_name or self.name or self.email or "Unknown"
    
    @property
    def first_name(self) -> str:
        """Extract first name from full name (computed property)"""
        if self.name:
            parts = self.name.split()
            return parts[0] if parts else self.name
        return ""
    
    @property
    def last_name(self) -> str:
        """Extract last name from full name (computed property)"""
        if self.name:
            parts = self.name.split()
            return " ".join(parts[1:]) if len(parts) > 1 else ""
        return ""