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
+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)