feat(phase-4a): backend skeleton, auth, health, tests
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Business logic service layer."""
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Auth service: register, login, token generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.models.org import Org
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.auth import UserRegisterRequest
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Base for auth service errors with an HTTP-friendly status code."""
|
||||
|
||||
status_code: int = 400
|
||||
|
||||
|
||||
class EmailAlreadyExists(AuthError):
|
||||
status_code = 409
|
||||
|
||||
|
||||
class BootstrapAlreadyCompleted(AuthError):
|
||||
status_code = 403
|
||||
|
||||
|
||||
class InvalidCredentials(AuthError):
|
||||
status_code = 401
|
||||
|
||||
|
||||
async def count_users(db: AsyncSession) -> int:
|
||||
"""Count active (non-soft-deleted) users. Used to gate bootstrap registration."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.deleted_at.is_(None))
|
||||
)
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
async def register_user(
|
||||
db: AsyncSession, payload: UserRegisterRequest
|
||||
) -> tuple[User, str]:
|
||||
"""Bootstrap registration.
|
||||
|
||||
Creates a new Org and the first user (or a new user in the existing org
|
||||
if the org is passed in — Phase 4a only supports bootstrap here).
|
||||
|
||||
Raises:
|
||||
BootstrapAlreadyCompleted: if users already exist (403).
|
||||
EmailAlreadyExists: if the email is already taken (409).
|
||||
|
||||
Returns:
|
||||
(user, jwt_token) tuple.
|
||||
"""
|
||||
existing = await count_users(db)
|
||||
if existing > 0:
|
||||
raise BootstrapAlreadyCompleted(
|
||||
"Bootstrap registration is disabled: users already exist. "
|
||||
"Use POST /api/v1/users (admin) to invite new users."
|
||||
)
|
||||
|
||||
# Check email uniqueness within the (new) org context
|
||||
org = Org(name=f"{payload.name}'s Org")
|
||||
db.add(org)
|
||||
await db.flush() # assigns org.id
|
||||
|
||||
user = User(
|
||||
org_id=org.id,
|
||||
email=payload.email.lower(),
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name,
|
||||
# First registered user is implicitly admin for bootstrap convenience.
|
||||
role=UserRole.admin,
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise EmailAlreadyExists(
|
||||
f"A user with email {payload.email!r} already exists."
|
||||
) from e
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
role_str = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
token = create_access_token(user.id, user.org_id, role_str)
|
||||
return user, token
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, email: str, password: str
|
||||
) -> Optional[tuple[User, str]]:
|
||||
"""Verify credentials and return (user, token) on success, None on failure.
|
||||
|
||||
The endpoint wraps this and returns 401 on None — keeping the
|
||||
service-level function free of HTTPException for testability.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.email == email.lower(),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
role_str = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
token = create_access_token(user.id, user.org_id, role_str)
|
||||
return user, token
|
||||
|
||||
|
||||
def build_token_response(user: User) -> str:
|
||||
"""Build a fresh access token for a user."""
|
||||
settings = get_settings()
|
||||
_ = settings # touch to ensure config is loaded
|
||||
return create_access_token(user.id, user.org_id, user.role.value)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""User service: read, update, soft-delete, list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.user import UserCreateRequest, UserUpdate
|
||||
|
||||
|
||||
class UserNotFound(Exception):
|
||||
"""Raised when a user lookup fails."""
|
||||
|
||||
|
||||
class EmailAlreadyTaken(Exception):
|
||||
"""Raised when attempting to create/update a user with an existing email."""
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
"""Fetch a user by ID (active only, soft-deleted excluded)."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.deleted_at.is_(None))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_email(
|
||||
db: AsyncSession, email: str, org_id: Optional[int] = None
|
||||
) -> Optional[User]:
|
||||
"""Fetch a user by email, optionally scoped to an org."""
|
||||
stmt = select(User).where(
|
||||
User.email == email.lower(),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(User.org_id == org_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_user_profile(
|
||||
db: AsyncSession, user: User, payload: UserUpdate, *, is_admin: bool = False
|
||||
) -> User:
|
||||
"""Apply partial updates to a user.
|
||||
|
||||
Non-admin callers cannot change the role. All other fields are optional.
|
||||
"""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "role" in data and not is_admin:
|
||||
# Silently drop role change for non-admin callers
|
||||
data.pop("role")
|
||||
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def soft_delete_user(db: AsyncSession, user: User) -> User:
|
||||
"""Soft-delete a user by setting deleted_at to now."""
|
||||
user.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def create_user_as_admin(
|
||||
db: AsyncSession, payload: UserCreateRequest, org_id: int
|
||||
) -> User:
|
||||
"""Create a new user in the given org (admin-only flow)."""
|
||||
existing = await get_user_by_email(db, payload.email, org_id=org_id)
|
||||
if existing is not None:
|
||||
raise EmailAlreadyTaken(
|
||||
f"A user with email {payload.email!r} already exists in this org."
|
||||
)
|
||||
|
||||
user = User(
|
||||
org_id=org_id,
|
||||
email=payload.email.lower(),
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name,
|
||||
role=payload.role if payload.role else UserRole.sales_rep,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def list_users(
|
||||
db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50
|
||||
) -> list[User]:
|
||||
"""List active users in an org, paginated."""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.org_id == org_id, User.deleted_at.is_(None))
|
||||
.order_by(User.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def count_users_in_org(db: AsyncSession, org_id: int) -> int:
|
||||
"""Count active users in an org."""
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.org_id == org_id, User.deleted_at.is_(None))
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
Reference in New Issue
Block a user