refactor(backend): update API routes and service layer
- Update agent router/service/repository with new endpoints - Update auth routes with phone-based authentication - Update users service with new phone lookup - Update schedule_items with new schemas - Update message schemas with visibility support - Update settings with new automation scheduler config - Update CLI with new commands - Update tests to match new API contracts
This commit is contained in:
@@ -76,11 +76,11 @@ async def _verify_user_with_supabase(token: str) -> CurrentUser | None:
|
||||
parsed_id = UUID(user_id)
|
||||
except ValueError:
|
||||
return None
|
||||
email = getattr(user, "email", None)
|
||||
phone = getattr(user, "phone", None)
|
||||
role = getattr(user, "role", None)
|
||||
return CurrentUser(
|
||||
id=parsed_id,
|
||||
email=email if isinstance(email, str) else None,
|
||||
phone=phone if isinstance(phone, str) else None,
|
||||
role=role if isinstance(role, str) else None,
|
||||
)
|
||||
|
||||
@@ -125,9 +125,9 @@ async def get_current_user(
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
logger.debug("JWT validation successful", user_id=str(user_id))
|
||||
email = payload.get("email") if isinstance(payload.get("email"), str) else None
|
||||
phone = payload.get("phone") if isinstance(payload.get("phone"), str) else None
|
||||
role = payload.get("role") if isinstance(payload.get("role"), str) else None
|
||||
return CurrentUser(id=user_id, email=email, role=role)
|
||||
return CurrentUser(id=user_id, phone=phone, role=role)
|
||||
|
||||
|
||||
async def get_user_repository(
|
||||
|
||||
@@ -38,7 +38,7 @@ class UserRepository(Protocol):
|
||||
...
|
||||
|
||||
async def search_users(self, query: str, limit: int = 20) -> list[Profile]:
|
||||
"""Search users by username (ilike) or email (exact match)."""
|
||||
"""Search users by username (ilike) or phone (exact match)."""
|
||||
...
|
||||
|
||||
|
||||
|
||||
@@ -21,19 +21,22 @@ if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from schemas.user.context import UserContext
|
||||
from v1.auth.schemas import UserByEmailResponse
|
||||
|
||||
logger = get_logger("v1.users.service")
|
||||
|
||||
_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
_PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$")
|
||||
|
||||
|
||||
class AuthLookupGateway(Protocol):
|
||||
async def get_user_id_by_email(self, email: str) -> str | None: ...
|
||||
async def search_user_ids_by_phone(
|
||||
self, query: str, limit: int = 20
|
||||
) -> list[str]: ...
|
||||
|
||||
|
||||
class AuthByEmailGateway(Protocol):
|
||||
async def get_user_by_email(self, email: str) -> "UserByEmailResponse": ...
|
||||
class AuthByPhoneGateway(Protocol):
|
||||
async def search_user_ids_by_phone(
|
||||
self, query: str, limit: int = 20
|
||||
) -> list[str]: ...
|
||||
|
||||
|
||||
class UserContextInvalidator(Protocol):
|
||||
@@ -41,15 +44,14 @@ class UserContextInvalidator(Protocol):
|
||||
|
||||
|
||||
class AuthLookupAdapter:
|
||||
def __init__(self, gateway: AuthByEmailGateway) -> None:
|
||||
def __init__(self, gateway: AuthByPhoneGateway) -> None:
|
||||
self._gateway = gateway
|
||||
|
||||
async def get_user_id_by_email(self, email: str) -> str | None:
|
||||
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
||||
try:
|
||||
response = await self._gateway.get_user_by_email(email)
|
||||
return response.id
|
||||
return await self._gateway.search_user_ids_by_phone(query, limit=limit)
|
||||
except HTTPException:
|
||||
return None
|
||||
return []
|
||||
|
||||
|
||||
class UserService(BaseService):
|
||||
@@ -92,11 +94,11 @@ class UserService(BaseService):
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
email = self._current_user.email if self._current_user else None
|
||||
phone = self._current_user.phone if self._current_user else None
|
||||
return UserContext(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
email=email,
|
||||
phone=phone,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
settings=parse_profile_settings(user.settings),
|
||||
@@ -152,11 +154,11 @@ class UserService(BaseService):
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
email = self._current_user.email if self._current_user else None
|
||||
phone = self._current_user.phone if self._current_user else None
|
||||
return UserContext(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
email=email,
|
||||
phone=phone,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
settings=parse_profile_settings(user.settings),
|
||||
@@ -181,36 +183,59 @@ class UserService(BaseService):
|
||||
async def search_users(self, request: UserSearchRequest) -> list[UserContext]:
|
||||
query = request.query.strip()
|
||||
|
||||
if _EMAIL_PATTERN.match(query):
|
||||
return await self._search_by_email(query)
|
||||
if _looks_like_phone_query(query):
|
||||
phone_results = await self._search_by_phone(query)
|
||||
if not query.isdigit():
|
||||
return phone_results
|
||||
username_results = await self._search_by_username(query)
|
||||
if not phone_results:
|
||||
return username_results
|
||||
merged_by_id = {result.id: result for result in phone_results}
|
||||
for result in username_results:
|
||||
merged_by_id.setdefault(result.id, result)
|
||||
return list(merged_by_id.values())
|
||||
|
||||
return await self._search_by_username(query)
|
||||
|
||||
async def _search_by_email(self, email: str) -> list[UserContext]:
|
||||
async def _search_by_phone(self, phone: str) -> list[UserContext]:
|
||||
if self._auth_gateway is None:
|
||||
raise HTTPException(status_code=503, detail="Auth lookup unavailable")
|
||||
|
||||
user_id_str = await self._auth_gateway.get_user_id_by_email(email)
|
||||
if user_id_str is None:
|
||||
user_id_values = await self._auth_gateway.search_user_ids_by_phone(
|
||||
phone, limit=20
|
||||
)
|
||||
if not user_id_values:
|
||||
return []
|
||||
|
||||
user_ids: list[UUID] = []
|
||||
for raw_id in user_id_values:
|
||||
try:
|
||||
user_ids.append(UUID(raw_id))
|
||||
except ValueError:
|
||||
continue
|
||||
if not user_ids:
|
||||
return []
|
||||
|
||||
try:
|
||||
user = await self._repository.get_by_user_id(UUID(user_id_str))
|
||||
users_by_id = await self._repository.get_by_user_ids(user_ids)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
return [
|
||||
UserContext(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
settings=parse_profile_settings(user.settings),
|
||||
results: list[UserContext] = []
|
||||
for user_id in user_ids:
|
||||
user = users_by_id.get(user_id)
|
||||
if user is None:
|
||||
continue
|
||||
results.append(
|
||||
UserContext(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
settings=parse_profile_settings(user.settings),
|
||||
)
|
||||
)
|
||||
]
|
||||
return results
|
||||
|
||||
async def _search_by_username(self, query: str) -> list[UserContext]:
|
||||
try:
|
||||
@@ -228,3 +253,10 @@ class UserService(BaseService):
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
def _looks_like_phone_query(query: str) -> bool:
|
||||
if not _PHONE_QUERY_PATTERN.fullmatch(query):
|
||||
return False
|
||||
digits_count = sum(char.isdigit() for char in query)
|
||||
return digits_count >= 4
|
||||
|
||||
Reference in New Issue
Block a user