Files
pragma-stack/backend/app/services/session_service.py
Felipe Cardoso 98b455fdc3 refactor(backend): enforce route→service→repo layered architecture
- introduce custom repository exception hierarchy (DuplicateEntryError,
  IntegrityConstraintError, InvalidInputError) replacing raw ValueError
- eliminate all direct repository imports and raw SQL from route layer
- add UserService, SessionService, OrganizationService to service layer
- add get_stats/get_org_distribution service methods replacing admin inline SQL
- fix timing side-channel in authenticate_user via dummy bcrypt check
- replace SHA-256 client secret fallback with explicit InvalidClientError
- replace assert with InvalidGrantError in authorization code exchange
- replace N+1 token revocation loops with bulk UPDATE statements
- rename oauth account token fields (drop misleading 'encrypted' suffix)
- add Alembic migration 0003 for token field column rename
- add 45 new service/repository tests; 975 passing, 94% coverage
2026-02-27 09:32:57 +01:00

98 lines
3.3 KiB
Python

# app/services/session_service.py
"""Service layer for session operations — delegates to SessionRepository."""
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user_session import UserSession
from app.repositories.session import SessionRepository, session_repo
from app.schemas.sessions import SessionCreate
logger = logging.getLogger(__name__)
class SessionService:
"""Service for user session management operations."""
def __init__(self, session_repository: SessionRepository | None = None) -> None:
self._repo = session_repository or session_repo
async def create_session(
self, db: AsyncSession, *, obj_in: SessionCreate
) -> UserSession:
"""Create a new session record."""
return await self._repo.create_session(db, obj_in=obj_in)
async def get_session(self, db: AsyncSession, session_id: str) -> UserSession | None:
"""Get session by ID."""
return await self._repo.get(db, id=session_id)
async def get_user_sessions(
self, db: AsyncSession, *, user_id: str, active_only: bool = True
) -> list[UserSession]:
"""Get all sessions for a user."""
return await self._repo.get_user_sessions(
db, user_id=user_id, active_only=active_only
)
async def get_active_by_jti(
self, db: AsyncSession, *, jti: str
) -> UserSession | None:
"""Get active session by refresh token JTI."""
return await self._repo.get_active_by_jti(db, jti=jti)
async def get_by_jti(self, db: AsyncSession, *, jti: str) -> UserSession | None:
"""Get session by refresh token JTI (active or inactive)."""
return await self._repo.get_by_jti(db, jti=jti)
async def deactivate(
self, db: AsyncSession, *, session_id: str
) -> UserSession | None:
"""Deactivate a session (logout from device)."""
return await self._repo.deactivate(db, session_id=session_id)
async def deactivate_all_user_sessions(
self, db: AsyncSession, *, user_id: str
) -> int:
"""Deactivate all sessions for a user. Returns count deactivated."""
return await self._repo.deactivate_all_user_sessions(db, user_id=user_id)
async def update_refresh_token(
self,
db: AsyncSession,
*,
session: UserSession,
new_jti: str,
new_expires_at: datetime,
) -> UserSession:
"""Update session with a rotated refresh token."""
return await self._repo.update_refresh_token(
db, session=session, new_jti=new_jti, new_expires_at=new_expires_at
)
async def cleanup_expired_for_user(
self, db: AsyncSession, *, user_id: str
) -> int:
"""Remove expired sessions for a user. Returns count removed."""
return await self._repo.cleanup_expired_for_user(db, user_id=user_id)
async def get_all_sessions(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
with_user: bool = True,
) -> tuple[list[UserSession], int]:
"""Get all sessions with pagination (admin only)."""
return await self._repo.get_all_sessions(
db, skip=skip, limit=limit, active_only=active_only, with_user=with_user
)
# Default singleton
session_service = SessionService()