diff --git a/backend/src/v1/agent_chat/router.py b/backend/src/v1/agent_chat/router.py index e886299..f1666b4 100644 --- a/backend/src/v1/agent_chat/router.py +++ b/backend/src/v1/agent_chat/router.py @@ -11,7 +11,7 @@ from v1.agent_chat.service import AgentChatService router = APIRouter(prefix="/agent-chat", tags=["agent-chat"]) -@router.post("/run", response_model=AgentChatRunResponse) +@router.post("", response_model=AgentChatRunResponse) async def run_agent_chat( payload: AgentChatRunRequest, service: Annotated[AgentChatService, Depends(get_agent_chat_service)], diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 6aa2b3e..266ab89 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -9,16 +9,15 @@ from supabase import AuthError, create_client from core.config.settings import SupabaseSettings, config from core.logging import get_logger from v1.auth.schemas import ( - AuthResendCodeResponse, - AuthSignupStartResponse, - AuthTokenResponse, AuthUser, - AuthUserByEmailResponse, - LoginRequest, - RefreshRequest, - SignupResendRequest, - SignupStartRequest, - SignupVerifyRequest, + SessionCreateRequest, + SessionRefreshRequest, + SessionResponse, + UserByEmailResponse, + VerificationCreateRequest, + VerificationCreateResponse, + VerificationResendRequest, + VerificationVerifyRequest, ) from v1.auth.service import AuthServiceGateway @@ -34,9 +33,9 @@ class SupabaseAuthGateway(AuthServiceGateway): self._client = create_client(settings.url, settings.anon_key) self._admin_client = create_client(settings.url, settings.service_role_key) - async def signup_start( - self, request: SignupStartRequest - ) -> AuthSignupStartResponse: + async def create_verification( + self, request: VerificationCreateRequest + ) -> VerificationCreateResponse: payload: dict[str, Any] = { "email": request.email, "password": request.password, @@ -47,14 +46,16 @@ class SupabaseAuthGateway(AuthServiceGateway): try: sign_up = cast(Any, self._client.auth.sign_up) await asyncio.to_thread(sign_up, payload) - return AuthSignupStartResponse(email=request.email) + return VerificationCreateResponse(email=request.email) except AuthError as exc: logger.warning("Signup failed", error_type=type(exc).__name__) raise HTTPException( status_code=422, detail="Invalid signup request" ) from exc - async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + async def verify_verification( + self, request: VerificationVerifyRequest + ) -> SessionResponse: payload: dict[str, Any] = { "type": "signup", "email": request.email, @@ -70,18 +71,15 @@ class SupabaseAuthGateway(AuthServiceGateway): status_code=401, detail="Invalid verification code" ) from exc - async def signup_resend( - self, request: SignupResendRequest - ) -> AuthResendCodeResponse: + async def resend_verification(self, request: VerificationResendRequest) -> None: payload: dict[str, Any] = {"type": "signup", "email": request.email} try: resend = cast(Any, self._client.auth.resend) await asyncio.to_thread(resend, payload) except AuthError as exc: logger.warning("Signup resend failed", error_type=type(exc).__name__) - return AuthResendCodeResponse() - async def login(self, request: LoginRequest) -> AuthTokenResponse: + async def create_session(self, request: SessionCreateRequest) -> SessionResponse: payload: dict[str, Any] = {"email": request.email, "password": request.password} try: sign_in = cast(Any, self._client.auth.sign_in_with_password) @@ -91,7 +89,7 @@ class SupabaseAuthGateway(AuthServiceGateway): logger.warning("Login failed", error_type=type(exc).__name__) raise HTTPException(status_code=401, detail="Invalid credentials") from exc - async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: try: response = await asyncio.to_thread( self._client.auth.refresh_session, @@ -104,7 +102,7 @@ class SupabaseAuthGateway(AuthServiceGateway): status_code=401, detail="Invalid refresh token" ) from exc - async def logout(self, refresh_token: str | None) -> None: + async def delete_session(self, refresh_token: str | None) -> None: if not refresh_token: raise HTTPException(status_code=401, detail="Missing refresh token") try: @@ -127,7 +125,7 @@ class SupabaseAuthGateway(AuthServiceGateway): status_code=401, detail="Invalid refresh token" ) from exc - async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + async def get_user_by_email(self, email: str) -> UserByEmailResponse: users = await asyncio.to_thread(_list_auth_users, self._admin_client) normalized_email = email.lower() user = next( @@ -141,7 +139,7 @@ class SupabaseAuthGateway(AuthServiceGateway): if user is None: raise HTTPException(status_code=404, detail="User not found") - return AuthUserByEmailResponse( + return UserByEmailResponse( id=str(getattr(user, "id", "")), email=str(getattr(user, "email", "")), created_at=str(getattr(user, "created_at", "")), @@ -153,7 +151,7 @@ class SupabaseAuthGateway(AuthServiceGateway): ) -def _map_auth_response(response: object, failure_message: str) -> AuthTokenResponse: +def _map_auth_response(response: object, failure_message: str) -> SessionResponse: session = getattr(response, "session", None) user = getattr(response, "user", None) if session is None or user is None: @@ -164,7 +162,7 @@ def _map_auth_response(response: object, failure_message: str) -> AuthTokenRespo raise HTTPException(status_code=401, detail=failure_message) auth_user = AuthUser(id=str(user.id), email=str(email)) - return AuthTokenResponse( + return SessionResponse( access_token=str(session.access_token), refresh_token=str(session.refresh_token), expires_in=int(session.expires_in or 0), diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 9d64f5e..97163e2 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -8,18 +8,17 @@ from fastapi import HTTPException from core.auth.models import CurrentUser from v1.auth.rate_limit import enforce_rate_limit from v1.auth.dependencies import get_auth_service -from v1.profile.dependencies import get_current_user +from v1.users.dependencies import get_current_user from v1.auth.schemas import ( - AuthResendCodeResponse, - AuthSignupStartResponse, - AuthTokenResponse, - AuthUserByEmailResponse, - LoginRequest, - LogoutRequest, - RefreshRequest, - SignupResendRequest, - SignupStartRequest, - SignupVerifyRequest, + SessionCreateRequest, + SessionDeleteRequest, + SessionRefreshRequest, + SessionResponse, + UserByEmailResponse, + VerificationCreateRequest, + VerificationCreateResponse, + VerificationResendRequest, + VerificationVerifyRequest, ) from v1.auth.service import AuthService @@ -27,79 +26,82 @@ from v1.auth.service import AuthService router = APIRouter(prefix="/auth", tags=["auth"]) -@router.post("/signup/start", response_model=AuthSignupStartResponse, status_code=202) -async def signup_start( - payload: SignupStartRequest, +@router.post( + "/verifications", response_model=VerificationCreateResponse, status_code=202 +) +async def create_verification( + payload: VerificationCreateRequest, service: AuthService = Depends(get_auth_service), -) -> AuthSignupStartResponse: +) -> VerificationCreateResponse: await enforce_rate_limit( scope="signup_start", identifier=payload.email, limit=5, window_seconds=60, ) - return await service.signup_start(payload) + return await service.create_verification(payload) -@router.post("/signup/verify", response_model=AuthTokenResponse) -async def signup_verify( - payload: SignupVerifyRequest, +@router.post("/verifications/verify", response_model=SessionResponse) +async def verify_verification( + payload: VerificationVerifyRequest, service: AuthService = Depends(get_auth_service), -) -> AuthTokenResponse: +) -> SessionResponse: await enforce_rate_limit( scope="signup_verify", identifier=payload.email, limit=10, window_seconds=600, ) - return await service.signup_verify(payload) + return await service.verify_verification(payload) -@router.post("/signup/resend", response_model=AuthResendCodeResponse) -async def signup_resend( - payload: SignupResendRequest, +@router.post("/verifications/resend", status_code=204) +async def resend_verification( + payload: VerificationResendRequest, service: AuthService = Depends(get_auth_service), -) -> AuthResendCodeResponse: +) -> Response: await enforce_rate_limit( scope="signup_resend", identifier=payload.email, limit=5, window_seconds=60, ) - return await service.signup_resend(payload) + await service.resend_verification(payload) + return Response(status_code=204) -@router.post("/login", response_model=AuthTokenResponse) -async def login( - payload: LoginRequest, +@router.post("/sessions", response_model=SessionResponse) +async def create_session( + payload: SessionCreateRequest, service: AuthService = Depends(get_auth_service), -) -> AuthTokenResponse: +) -> SessionResponse: await enforce_rate_limit( scope="login", identifier=payload.email, limit=10, window_seconds=60, ) - return await service.login(payload) + return await service.create_session(payload) -@router.post("/refresh", response_model=AuthTokenResponse) -async def refresh( - payload: RefreshRequest, +@router.post("/sessions/refresh", response_model=SessionResponse) +async def refresh_session( + payload: SessionRefreshRequest, service: AuthService = Depends(get_auth_service), -) -> AuthTokenResponse: +) -> SessionResponse: await enforce_rate_limit( scope="refresh", identifier=payload.refresh_token, limit=10, window_seconds=60, ) - return await service.refresh(payload) + return await service.refresh_session(payload) -@router.post("/logout", status_code=204) -async def logout( - payload: LogoutRequest, +@router.delete("/sessions", status_code=204) +async def delete_session( + payload: SessionDeleteRequest, service: AuthService = Depends(get_auth_service), ) -> Response: await enforce_rate_limit( @@ -108,16 +110,16 @@ async def logout( limit=10, window_seconds=60, ) - await service.logout(payload.refresh_token) + await service.delete_session(payload.refresh_token) return Response(status_code=204) -@router.get("/users/by-email", response_model=AuthUserByEmailResponse) +@router.get("/users", response_model=UserByEmailResponse) async def get_user_by_email( email: str, current_user: Annotated[CurrentUser, Depends(get_current_user)], service: AuthService = Depends(get_auth_service), -) -> AuthUserByEmailResponse: +) -> UserByEmailResponse: if current_user.role != "service_role" and current_user.email != email: raise HTTPException(status_code=403, detail="Forbidden") return await service.get_user_by_email(email) diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 9d9840f..9c564f1 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -3,42 +3,41 @@ from __future__ import annotations from typing import Protocol from v1.auth.schemas import ( - AuthResendCodeResponse, - AuthSignupStartResponse, - AuthTokenResponse, - AuthUserByEmailResponse, - LoginRequest, - RefreshRequest, - SignupResendRequest, - SignupStartRequest, - SignupVerifyRequest, + SessionCreateRequest, + SessionRefreshRequest, + SessionResponse, + UserByEmailResponse, + VerificationCreateRequest, + VerificationCreateResponse, + VerificationResendRequest, + VerificationVerifyRequest, ) class AuthServiceGateway(Protocol): - async def signup_start( - self, request: SignupStartRequest - ) -> AuthSignupStartResponse: + async def create_verification( + self, request: VerificationCreateRequest + ) -> VerificationCreateResponse: raise NotImplementedError - async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: + async def verify_verification( + self, request: VerificationVerifyRequest + ) -> SessionResponse: raise NotImplementedError - async def signup_resend( - self, request: SignupResendRequest - ) -> AuthResendCodeResponse: + async def resend_verification(self, request: VerificationResendRequest) -> None: raise NotImplementedError - async def login(self, request: LoginRequest) -> AuthTokenResponse: + async def create_session(self, request: SessionCreateRequest) -> SessionResponse: raise NotImplementedError - async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: raise NotImplementedError - async def logout(self, refresh_token: str | None) -> None: + async def delete_session(self, refresh_token: str | None) -> None: raise NotImplementedError - async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + async def get_user_by_email(self, email: str) -> UserByEmailResponse: raise NotImplementedError @@ -48,27 +47,27 @@ class AuthService: def __init__(self, gateway: AuthServiceGateway) -> None: self._gateway = gateway - async def signup_start( - self, request: SignupStartRequest - ) -> AuthSignupStartResponse: - return await self._gateway.signup_start(request) + async def create_verification( + self, request: VerificationCreateRequest + ) -> VerificationCreateResponse: + return await self._gateway.create_verification(request) - async def signup_verify(self, request: SignupVerifyRequest) -> AuthTokenResponse: - return await self._gateway.signup_verify(request) + async def verify_verification( + self, request: VerificationVerifyRequest + ) -> SessionResponse: + return await self._gateway.verify_verification(request) - async def signup_resend( - self, request: SignupResendRequest - ) -> AuthResendCodeResponse: - return await self._gateway.signup_resend(request) + async def resend_verification(self, request: VerificationResendRequest) -> None: + await self._gateway.resend_verification(request) - async def login(self, request: LoginRequest) -> AuthTokenResponse: - return await self._gateway.login(request) + async def create_session(self, request: SessionCreateRequest) -> SessionResponse: + return await self._gateway.create_session(request) - async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: - return await self._gateway.refresh(request) + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + return await self._gateway.refresh_session(request) - async def logout(self, refresh_token: str | None) -> None: - await self._gateway.logout(refresh_token) + async def delete_session(self, refresh_token: str | None) -> None: + await self._gateway.delete_session(refresh_token) - async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + async def get_user_by_email(self, email: str) -> UserByEmailResponse: return await self._gateway.get_user_by_email(email) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 754a6a7..3a7b901 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -6,13 +6,13 @@ from core.http.models import HealthResponse from v1.agent_chat.router import router as agent_chat_router from v1.auth.router import router as auth_router from v1.infra.router import router as infra_router -from v1.profile.router import router as profile_router +from v1.users.router import router as users_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) router.include_router(infra_router) -router.include_router(profile_router) +router.include_router(users_router) router.include_router(agent_chat_router) diff --git a/backend/src/v1/users/router.py b/backend/src/v1/users/router.py new file mode 100644 index 0000000..e4825f6 --- /dev/null +++ b/backend/src/v1/users/router.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path + +from v1.users.dependencies import get_user_service +from v1.users.schemas import UserResponse, UserUpdateRequest +from v1.users.service import UserService + + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me", response_model=UserResponse) +async def get_me( + service: Annotated[UserService, Depends(get_user_service)], +) -> UserResponse: + return await service.get_me() + + +@router.patch("/me", response_model=UserResponse) +async def update_me( + payload: UserUpdateRequest, + service: Annotated[UserService, Depends(get_user_service)], +) -> UserResponse: + return await service.update_me(payload) + + +@router.get("/{username}", response_model=UserResponse) +async def get_by_username( + username: Annotated[ + str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$") + ], + service: Annotated[UserService, Depends(get_user_service)], +) -> UserResponse: + return await service.get_by_username(username)