feat(phase-4a): backend skeleton, auth, health, tests
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user