feat(phase-4a): backend skeleton, auth, health, tests

This commit is contained in:
CRM Bot
2026-06-03 20:52:01 +00:00
commit 955607f730
39 changed files with 2709 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""CRM System Application Package."""
__version__ = "1.0.0"
+1
View File
@@ -0,0 +1 @@
"""API routers package."""
+1
View File
@@ -0,0 +1 @@
"""API v1 routers."""
+156
View File
@@ -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()
+69
View File
@@ -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__,
}
+155
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Core module: configuration, database, security, dependencies."""
+86
View File
@@ -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]
+69
View File
@@ -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()
+68
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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()
+15
View File
@@ -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",
]
+52
View File
@@ -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"]
+34
View File
@@ -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}>"
+50
View File
@@ -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}>"
+1
View File
@@ -0,0 +1 @@
"""Pydantic schemas for request/response validation."""
+61
View File
@@ -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()
+56
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Business logic service layer."""
+126
View File
@@ -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)
+120
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Static frontend assets (Phase 4c fills this)."""