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
This commit is contained in:
2026-02-27 09:32:57 +01:00
parent 0646c96b19
commit 98b455fdc3
62 changed files with 2933 additions and 1728 deletions

View File

@@ -0,0 +1,120 @@
# app/services/user_service.py
"""Service layer for user operations — delegates to UserRepository."""
import logging
from typing import Any
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import NotFoundError
from app.models.user import User
from app.repositories.user import UserRepository, user_repo
from app.schemas.users import UserCreate, UserUpdate
logger = logging.getLogger(__name__)
class UserService:
"""Service for user management operations."""
def __init__(self, user_repository: UserRepository | None = None) -> None:
self._repo = user_repository or user_repo
async def get_user(self, db: AsyncSession, user_id: str) -> User:
"""Get user by ID, raising NotFoundError if not found."""
user = await self._repo.get(db, id=user_id)
if not user:
raise NotFoundError(f"User {user_id} not found")
return user
async def get_by_email(self, db: AsyncSession, email: str) -> User | None:
"""Get user by email address."""
return await self._repo.get_by_email(db, email=email)
async def create_user(self, db: AsyncSession, user_data: UserCreate) -> User:
"""Create a new user."""
return await self._repo.create(db, obj_in=user_data)
async def update_user(
self, db: AsyncSession, *, user: User, obj_in: UserUpdate | dict[str, Any]
) -> User:
"""Update an existing user."""
return await self._repo.update(db, db_obj=user, obj_in=obj_in)
async def soft_delete_user(self, db: AsyncSession, user_id: str) -> None:
"""Soft-delete a user by ID."""
await self._repo.soft_delete(db, id=user_id)
async def list_users(
self,
db: AsyncSession,
*,
skip: int = 0,
limit: int = 100,
sort_by: str | None = None,
sort_order: str = "asc",
filters: dict[str, Any] | None = None,
search: str | None = None,
) -> tuple[list[User], int]:
"""List users with pagination, sorting, filtering, and search."""
return await self._repo.get_multi_with_total(
db,
skip=skip,
limit=limit,
sort_by=sort_by,
sort_order=sort_order,
filters=filters,
search=search,
)
async def bulk_update_status(
self, db: AsyncSession, *, user_ids: list[UUID], is_active: bool
) -> int:
"""Bulk update active status for multiple users. Returns count updated."""
return await self._repo.bulk_update_status(
db, user_ids=user_ids, is_active=is_active
)
async def bulk_soft_delete(
self,
db: AsyncSession,
*,
user_ids: list[UUID],
exclude_user_id: UUID | None = None,
) -> int:
"""Bulk soft-delete multiple users. Returns count deleted."""
return await self._repo.bulk_soft_delete(
db, user_ids=user_ids, exclude_user_id=exclude_user_id
)
async def get_stats(self, db: AsyncSession) -> dict[str, Any]:
"""Return user stats needed for the admin dashboard."""
from sqlalchemy import func, select
total_users = (
await db.execute(select(func.count()).select_from(User))
).scalar() or 0
active_count = (
await db.execute(select(func.count()).select_from(User).where(User.is_active))
).scalar() or 0
inactive_count = (
await db.execute(
select(func.count()).select_from(User).where(User.is_active.is_(False))
)
).scalar() or 0
all_users = list(
(
await db.execute(select(User).order_by(User.created_at))
).scalars().all()
)
return {
"total_users": total_users,
"active_count": active_count,
"inactive_count": inactive_count,
"all_users": all_users,
}
# Default singleton
user_service = UserService()