feat(phase-4a): backend skeleton, auth, health, tests
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""CRM System Application Package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API v1 routers."""
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Auth API endpoints: register, login, refresh, logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.db import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
LogoutResponse,
|
||||
RegisterResponse,
|
||||
TokenResponse,
|
||||
UserLoginRequest,
|
||||
UserRegisterRequest,
|
||||
)
|
||||
from app.schemas.user import UserOut
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=RegisterResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Bootstrap user registration (only allowed if users table is empty)",
|
||||
)
|
||||
async def register(
|
||||
payload: UserRegisterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RegisterResponse:
|
||||
"""Create the first user and a default organization.
|
||||
|
||||
Returns 403 after the first user has been registered.
|
||||
"""
|
||||
try:
|
||||
user, token = await auth_service.register_user(db, payload)
|
||||
except auth_service.BootstrapAlreadyCompleted as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except auth_service.EmailAlreadyExists as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
|
||||
settings = get_settings()
|
||||
return RegisterResponse(
|
||||
user=UserOut.model_validate(user),
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
summary="Login with email + password (form-data or JSON)",
|
||||
)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""OAuth2-compatible login. `username` field carries the email.
|
||||
|
||||
Returns 401 on invalid credentials — never leaks whether the email exists.
|
||||
"""
|
||||
result = await auth_service.authenticate_user(
|
||||
db, email=form_data.username, password=form_data.password
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
_user, token = result
|
||||
settings = get_settings()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/json",
|
||||
response_model=TokenResponse,
|
||||
summary="Login with JSON body (alternative to form-data)",
|
||||
)
|
||||
async def login_json(
|
||||
payload: UserLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""JSON-body login variant for clients that prefer JSON over form-data."""
|
||||
result = await auth_service.authenticate_user(
|
||||
db, email=payload.email, password=payload.password
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
_user, token = result
|
||||
settings = get_settings()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="Issue a new JWT for the current user",
|
||||
)
|
||||
async def refresh(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> TokenResponse:
|
||||
"""Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret."""
|
||||
settings = get_settings()
|
||||
from app.core.security import create_access_token
|
||||
|
||||
role_str = (
|
||||
current_user.role.value
|
||||
if hasattr(current_user.role, "value")
|
||||
else str(current_user.role)
|
||||
)
|
||||
token = create_access_token(current_user.id, current_user.org_id, role_str)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=LogoutResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Logout (client-side token discard)",
|
||||
)
|
||||
async def logout(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
) -> LogoutResponse:
|
||||
"""Stateless logout. Client deletes the token from localStorage.
|
||||
|
||||
The endpoint validates the token (so a stolen token can be detected on logout)
|
||||
but does not maintain a server-side blacklist in v1.
|
||||
"""
|
||||
return LogoutResponse()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Health check endpoints: /health (root) and /api/v1/health."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import __version__
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
async def _check_db(db: AsyncSession) -> bool:
|
||||
"""Run SELECT 1 to verify the database connection is alive."""
|
||||
try:
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
result.scalar_one()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Database health check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
summary="Healthcheck (used by Coolify + Kubernetes)",
|
||||
)
|
||||
async def health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Returns 200 if the API and DB are healthy, 503 if the DB is down."""
|
||||
db_ok = await _check_db(db)
|
||||
if not db_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "error", "db": "down"},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/health",
|
||||
summary="Versioned healthcheck (for clients that hit /api/v1/*)",
|
||||
)
|
||||
async def api_v1_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Same as /health but under the /api/v1 prefix for versioned routing."""
|
||||
db_ok = await _check_db(db)
|
||||
if not db_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "error", "db": "down"},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
"version": __version__,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""User API endpoints: me, list, create, update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_admin_user, get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services import user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Get the currently authenticated user",
|
||||
)
|
||||
async def get_me(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserOut:
|
||||
"""Returns the user from the JWT. Used by the frontend for auth checks and profile display."""
|
||||
return UserOut.model_validate(current_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Update own profile (name, avatar, notification prefs)",
|
||||
)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""A user can update their own profile, but not their role."""
|
||||
updated = await user_service.update_user_profile(
|
||||
db, current_user, payload, is_admin=False
|
||||
)
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=UserListResponse,
|
||||
summary="List all users in the current org (admin only)",
|
||||
)
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserListResponse:
|
||||
"""Paginated list of active users. Restricted to admin role."""
|
||||
skip = (page - 1) * page_size
|
||||
users = await user_service.list_users(
|
||||
db, org_id=current_user.org_id, skip=skip, limit=page_size
|
||||
)
|
||||
total = await user_service.count_users_in_org(db, current_user.org_id)
|
||||
return UserListResponse(
|
||||
items=[UserOut.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=UserOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new user in the current org (admin only)",
|
||||
)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""Admin creates a new user in the same org."""
|
||||
try:
|
||||
new_user = await user_service.create_user_as_admin(
|
||||
db, payload, org_id=current_user.org_id
|
||||
)
|
||||
except user_service.EmailAlreadyTaken as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
return UserOut.model_validate(new_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserOut,
|
||||
summary="Update a user (admin or self for non-role fields)",
|
||||
)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""Admin can update any user (including role); non-admin can update only their own profile fields."""
|
||||
is_admin = (
|
||||
current_user.role == UserRole.admin
|
||||
if hasattr(current_user.role, "__eq__") and not isinstance(current_user.role, str)
|
||||
else str(current_user.role) == UserRole.admin.value
|
||||
)
|
||||
if not is_admin and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can only update your own profile",
|
||||
)
|
||||
|
||||
target = await user_service.get_user_by_id(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if target.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
updated = await user_service.update_user_profile(
|
||||
db, target, payload, is_admin=is_admin
|
||||
)
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a user (admin only)",
|
||||
)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Sets deleted_at timestamp. The record stays in the DB for audit purposes."""
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="You cannot delete your own account"
|
||||
)
|
||||
|
||||
target = await user_service.get_user_by_id(db, user_id)
|
||||
if target is None or target.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
await user_service.soft_delete_user(db, target)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module: configuration, database, security, dependencies."""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Application configuration via Pydantic-Settings v2.
|
||||
|
||||
Loads from .env file and environment variables.
|
||||
Hard-fails if AUTH_SECRET is missing or shorter than 32 characters
|
||||
(per 02-architecture.md Section 13.5 R-5: NO JWT secret fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# === Database ===
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db"
|
||||
|
||||
# === JWT / Auth ===
|
||||
# NO DEFAULT — hard-fail if missing (R-5)
|
||||
AUTH_SECRET: str = Field(..., min_length=32)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRY_HOURS: int = 24
|
||||
|
||||
# === Password Hashing ===
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# === CORS ===
|
||||
# Comma-separated list of allowed origins, NO wildcards
|
||||
CORS_ORIGINS: str = "http://localhost:5500,http://localhost:8000"
|
||||
|
||||
# === Runtime ===
|
||||
ENVIRONMENT: Literal["development", "production", "test"] = "development"
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
|
||||
@field_validator("AUTH_SECRET")
|
||||
@classmethod
|
||||
def validate_auth_secret(cls, v: str) -> str:
|
||||
"""Ensure AUTH_SECRET is at least 32 characters and not a known dev default."""
|
||||
if len(v) < 32:
|
||||
raise ValueError(
|
||||
f"AUTH_SECRET must be at least 32 characters (got {len(v)}). "
|
||||
"Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'"
|
||||
)
|
||||
# Reject obvious placeholders
|
||||
lowered = v.lower()
|
||||
if "replace-me" in lowered or "changeme" in lowered or "secret" == lowered:
|
||||
raise ValueError("AUTH_SECRET appears to be a placeholder. Use a real random value.")
|
||||
return v
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS_ORIGINS into a list of trimmed, non-empty origins."""
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""Check if running in production mode."""
|
||||
return self.ENVIRONMENT == "production"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
"""Check if running in test mode."""
|
||||
return self.ENVIRONMENT == "test"
|
||||
|
||||
@property
|
||||
def jwt_expiry_seconds(self) -> int:
|
||||
"""JWT expiry in seconds (for response `expires_in` field)."""
|
||||
return self.JWT_EXPIRY_HOURS * 3600
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance."""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Async SQLAlchemy engine, session factory, and FastAPI dependency.
|
||||
|
||||
Driver-agnostic: works with both aiosqlite (dev) and asyncpg (prod).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models.
|
||||
|
||||
Re-exported here so Alembic env.py can import it from a single location.
|
||||
"""
|
||||
|
||||
|
||||
# === Engine & Session Factory ===
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# SQLite needs check_same_thread=False even for async — handled by aiosqlite.
|
||||
# echo=False in production; controlled by env if needed later.
|
||||
_engine_kwargs: dict[str, Any] = {"echo": False, "future": True}
|
||||
|
||||
# For PostgreSQL asyncpg, pool sizing matters; for SQLite it's irrelevant.
|
||||
if not _settings.DATABASE_URL.startswith("sqlite"):
|
||||
_engine_kwargs["pool_size"] = 5
|
||||
_engine_kwargs["max_overflow"] = 10
|
||||
_engine_kwargs["pool_pre_ping"] = True
|
||||
|
||||
engine: AsyncEngine = create_async_engine(_settings.DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
# === Dependency ===
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an AsyncSession and ensures cleanup."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
# No explicit close needed — async context manager handles it.
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose of the engine on application shutdown."""
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""FastAPI dependency functions for auth and role checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the current authenticated user from the JWT in the Authorization header.
|
||||
|
||||
Raises 401 on invalid/expired token, missing user, or soft-deleted user.
|
||||
"""
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exc
|
||||
|
||||
sub = payload.get("sub")
|
||||
if sub is None:
|
||||
raise credentials_exc
|
||||
|
||||
try:
|
||||
user_id = int(sub)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exc from None
|
||||
|
||||
# Load user fresh from DB to honor soft-delete and role changes
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.deleted_at.is_(None))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise credentials_exc
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Require the current user to have the admin role."""
|
||||
user_role = (
|
||||
user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
)
|
||||
if user_role != UserRole.admin.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Password hashing and JWT encoding/decoding.
|
||||
|
||||
Uses python-jose[cryptography] (per 02-architecture.md Section 13.1) and
|
||||
passlib[bcrypt] with bcrypt 4.0.1 (per Section 13.6, 03a-patterns R-6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# === Password Hashing ===
|
||||
|
||||
# CryptContext auto-upgrades old hashes when verified. bcrypt 4.0.1 is pinned
|
||||
# because 4.1+ breaks passlib (per 03a-patterns-summary R-6).
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password using bcrypt with the configured rounds."""
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a bcrypt hash."""
|
||||
try:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# === JWT ===
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
org_id: int,
|
||||
role: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token with the given user, org, and role claims.
|
||||
|
||||
The token contains:
|
||||
- sub: user id (string)
|
||||
- org_id: organization id
|
||||
- role: user role
|
||||
- exp: expiry timestamp
|
||||
- iat: issued-at timestamp
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(hours=_settings.JWT_EXPIRY_HOURS)
|
||||
expire = now + expires_delta
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"org_id": org_id,
|
||||
"role": role,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, _settings.AUTH_SECRET, algorithm=_settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
"""Decode and validate a JWT access token.
|
||||
|
||||
Returns the payload dict on success, or None on any error
|
||||
(invalid signature, malformed token, expired).
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
_settings.AUTH_SECRET,
|
||||
algorithms=[_settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def is_token_expired(token: str) -> bool:
|
||||
"""Check whether a token is expired without raising on other errors."""
|
||||
try:
|
||||
jwt.get_unverified_claims(token)
|
||||
# If decode succeeds, it's not expired.
|
||||
jwt.decode(
|
||||
token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM]
|
||||
)
|
||||
return False
|
||||
except JWTError as e:
|
||||
return "expired" in str(e).lower() or "exp" in str(e).lower()
|
||||
except Exception:
|
||||
return True
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
"""FastAPI application entry point.
|
||||
|
||||
Wires up:
|
||||
- CORS middleware (whitelist, NO wildcard)
|
||||
- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod)
|
||||
- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError)
|
||||
- Startup/shutdown events (DB connection check, log level)
|
||||
- Versioned API router mounts under /api/v1
|
||||
- Root-level /health endpoint for Coolify healthcheck
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app import __version__
|
||||
from app.api.v1 import auth, health, users
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import AsyncSessionLocal, dispose_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# === Lifespan ===
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown hooks."""
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||
logger.info(
|
||||
"Starting CRM System v%s in %s mode",
|
||||
__version__,
|
||||
settings.ENVIRONMENT,
|
||||
)
|
||||
|
||||
# Verify DB connection on startup
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0])
|
||||
except Exception as e:
|
||||
logger.error("Database connection FAILED on startup: %s", e)
|
||||
# Don't crash — let /health report the issue so Coolify can restart
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down CRM System")
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
# === App ===
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Application factory (allows easy testing override)."""
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="CRM System",
|
||||
version=__version__,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === CORS ===
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# === Security Headers ===
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
"""Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers."""
|
||||
response = await call_next(request)
|
||||
settings_local = get_settings()
|
||||
|
||||
if settings_local.is_production:
|
||||
# Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce)
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' https://cdn.tailwindcss.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"object-src 'none';"
|
||||
)
|
||||
else:
|
||||
# Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks)
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"object-src 'none';"
|
||||
)
|
||||
|
||||
response.headers["Content-Security-Policy"] = csp
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
if settings_local.is_production:
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=31536000; includeSubDomains"
|
||||
)
|
||||
return response
|
||||
|
||||
# === Exception Handlers ===
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(
|
||||
request: Request, exc: StarletteHTTPException
|
||||
) -> JSONResponse:
|
||||
"""Format HTTPException responses consistently."""
|
||||
# Distinguish token-expired for FR-1.7 acceptance criterion
|
||||
if exc.status_code == 401:
|
||||
detail = exc.detail
|
||||
if detail == "Could not validate credentials":
|
||||
# Token invalid or expired — callers can detect this
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": "token_expired_or_invalid"},
|
||||
headers=exc.headers,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Format Pydantic validation errors consistently."""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def sqlalchemy_exception_handler(
|
||||
request: Request, exc: SQLAlchemyError
|
||||
) -> JSONResponse:
|
||||
"""Log DB errors and return a 500 without leaking internals."""
|
||||
logger.exception("Database error on %s %s", request.method, request.url)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Database error"},
|
||||
)
|
||||
|
||||
# === Routers ===
|
||||
# Health is mounted at both /health (root) and /api/v1/health (versioned)
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(users.router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy ORM models for the CRM system."""
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
from app.models.org import Org
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Org",
|
||||
"OrgScopedMixin",
|
||||
"SoftDeleteMixin",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Base model and reusable mixins for the CRM system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds created_at and updated_at columns to a model."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Adds deleted_at column for soft-delete pattern."""
|
||||
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
class OrgScopedMixin:
|
||||
"""Adds org_id foreign key. All queries must filter by org_id (OrgScopedQuery)."""
|
||||
|
||||
org_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("orgs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Base", "OrgScopedMixin", "SoftDeleteMixin", "TimestampMixin"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Organization model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Org(Base, TimestampMixin):
|
||||
__tablename__ = "orgs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||||
default_currency: Mapped[str] = mapped_column(
|
||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
||||
)
|
||||
|
||||
# Relationship to users (defined here to resolve circular import)
|
||||
users: Mapped[list["User"]] = relationship(
|
||||
back_populates="org",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Org id={self.id} name={self.name!r}>"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""User model with role enum and soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.org import Org
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""User role for RBAC."""
|
||||
|
||||
admin = "admin"
|
||||
sales_manager = "sales_manager"
|
||||
sales_rep = "sales_rep"
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("org_id", "email", name="uq_users_org_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=UserRole.sales_rep,
|
||||
server_default=UserRole.sales_rep.value,
|
||||
)
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||||
email_notifications: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="1"
|
||||
)
|
||||
|
||||
# Relationship back to org (string reference avoids circular import at runtime)
|
||||
org: Mapped["Org"] = relationship(back_populates="users", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} email={self.email!r} role={self.role}>"
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas for request/response validation."""
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pydantic schemas for authentication endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserRegisterRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/auth/register (bootstrap)."""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: UserRole = UserRole.sales_rep
|
||||
|
||||
|
||||
class UserLoginRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/auth/login (form-data or JSON)."""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Response body for successful auth (register/login/refresh)."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
"""Response body for POST /api/v1/auth/logout.
|
||||
|
||||
The token is deleted client-side; this endpoint exists for consistency and
|
||||
future server-side blacklisting.
|
||||
"""
|
||||
|
||||
message: str = "logged out"
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""Response body for successful registration.
|
||||
|
||||
Returns the user info (without password) plus an access token.
|
||||
"""
|
||||
|
||||
user: "UserOut"
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
# Late import to avoid circular dependency
|
||||
from app.schemas.user import UserOut # noqa: E402
|
||||
|
||||
RegisterResponse.model_rebuild()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Pydantic schemas for User endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Response schema for a user (never includes password_hash)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: UserRole
|
||||
org_id: int
|
||||
avatar_url: Optional[str] = None
|
||||
email_notifications: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/v1/users/{id} (admin or self)."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
avatar_url: Optional[str] = Field(None, max_length=1024)
|
||||
email_notifications: Optional[bool] = None
|
||||
role: Optional[UserRole] = None # admin-only
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/users (admin-only)."""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: UserRole = UserRole.sales_rep
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Response schema for paginated user list."""
|
||||
|
||||
items: list[UserOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# Re-export for late import in auth.py
|
||||
__all__ = ["UserCreateRequest", "UserListResponse", "UserOut", "UserUpdate"]
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Static frontend assets (Phase 4c fills this)."""
|
||||
Reference in New Issue
Block a user