File size: 3,943 Bytes
49e9f9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from datetime import datetime
from enum import Enum
from typing import List, Optional

from pydantic import BaseModel, EmailStr, Field, field_validator

from .alert import VolunteerSkill  # shared enum


class UserRole(str, Enum):
    reporter = "reporter"
    volunteer = "volunteer"


class GeoPoint(BaseModel):
    type: str = Field(default="Point")
    coordinates: List[float] = Field(min_length=2, max_length=2)

    @field_validator("coordinates")
    @classmethod
    def validate_lng_lat(cls, v: list[float]) -> list[float]:
        lng, lat = v
        if not (-180 <= lng <= 180):
            raise ValueError("longitude must be between -180 and 180")
        if not (-90 <= lat <= 90):
            raise ValueError("latitude must be between -90 and 90")
        return v

    @field_validator("type")
    @classmethod
    def force_point(cls, v: str) -> str:
        if v != "Point":
            raise ValueError("GeoPoint.type must be 'Point'")
        return v


class EmergencyContact(BaseModel):
    """A trusted contact pinged client-side on SOS or "need help" check-ins.
    Kept minimal — the backend never sends SMS/email itself; the client
    opens the relevant tel:/sms:/mailto: link on tap."""

    name: str = Field(min_length=1, max_length=80)
    phone: Optional[str] = Field(default=None, max_length=32)
    email: Optional[EmailStr] = None

    @field_validator("name")
    @classmethod
    def strip_name(cls, v: str) -> str:
        return v.strip()

    @field_validator("phone")
    @classmethod
    def strip_phone(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        v = v.strip()
        return v or None


class UserCreate(BaseModel):
    name: str = Field(min_length=2, max_length=80)
    email: EmailStr
    # Strengthened from 6 chars to 8 with at-least-one-letter,
    # at-least-one-digit. Doesn't force special chars (which the OWASP
    # 2024 guidance now considers an anti-pattern that drives users
    # toward `Password!1` clones), but blocks the lowest-effort
    # passwords like "123456" or "password".
    password: str = Field(min_length=8, max_length=128)
    role: UserRole
    location: GeoPoint
    # Volunteer-only optional fields. Ignored for reporters.
    skills: List[VolunteerSkill] = Field(default_factory=list)
    has_vehicle: bool = False
    emergency_contacts: List[EmergencyContact] = Field(default_factory=list, max_length=5)

    @field_validator("name")
    @classmethod
    def strip_name(cls, v: str) -> str:
        stripped = v.strip()
        if len(stripped) < 2:
            raise ValueError("name must be at least 2 non-space characters")
        return stripped

    @field_validator("password")
    @classmethod
    def password_complexity(cls, v: str) -> str:
        # Require at least one letter and one digit. Avoids the worst
        # passwords without the security-theatre of mandatory specials.
        if not any(ch.isalpha() for ch in v) or not any(ch.isdigit() for ch in v):
            raise ValueError(
                "password must contain at least one letter and one digit"
            )
        return v


class UserLogin(BaseModel):
    email: EmailStr
    password: str = Field(min_length=1, max_length=128)


class LocationUpdate(BaseModel):
    location: GeoPoint


class ProfileUpdate(BaseModel):
    """Partial update for fields a user can change post-registration. Any
    None field is left untouched."""

    skills: Optional[List[VolunteerSkill]] = None
    has_vehicle: Optional[bool] = None
    emergency_contacts: Optional[List[EmergencyContact]] = Field(default=None, max_length=5)


class UserOut(BaseModel):
    id: str
    name: str
    email: str
    role: UserRole
    location: GeoPoint
    skills: List[VolunteerSkill] = Field(default_factory=list)
    has_vehicle: bool = False
    emergency_contacts: List[EmergencyContact] = Field(default_factory=list)
    created_at: datetime