feat: add FastAPI backend with PostgreSQL and start script fixes

- Add server/ directory with FastAPI backend
- Fix server/start.sh to properly handle venv on Windows/Git Bash
- Add alembic migrations and pyproject.toml
- Add server tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 17:43:47 +08:00
parent 9785fb527b
commit 83d7da3d62
46 changed files with 1438 additions and 9 deletions

View File

@@ -0,0 +1 @@
__all__ = ["router"]

View File

@@ -0,0 +1,13 @@
from collections.abc import Generator
from sqlalchemy.orm import Session
from app.db.session import get_session_factory
def get_db() -> Generator[Session, None, None]:
db = get_session_factory()()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.v1.router import router as v1_router
api_router = APIRouter()
api_router.include_router(v1_router)

View File

@@ -0,0 +1 @@
__all__ = ["router"]

View File

@@ -0,0 +1 @@
__all__ = ["health", "employees", "reimbursements"]

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from fastapi import APIRouter, status
from app.core.bootstrap import build_bootstrap_state, persist_bootstrap_config
from app.core.config import get_settings
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
router = APIRouter(prefix="/bootstrap")
@router.get("", response_model=BootstrapStateRead)
def get_bootstrap_state() -> BootstrapStateRead:
return build_bootstrap_state(get_settings())
@router.post("", response_model=BootstrapStateRead, status_code=status.HTTP_201_CREATED)
def initialize_bootstrap(payload: BootstrapSetupPayload) -> BootstrapStateRead:
return persist_bootstrap_config(payload, get_settings())

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.employee import EmployeeCreate, EmployeeRead
from app.services.employee import EmployeeService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("", response_model=list[EmployeeRead])
def list_employees(db: DbSession) -> list[EmployeeRead]:
return EmployeeService(db).list_employees()
@router.post("", response_model=EmployeeRead, status_code=status.HTTP_201_CREATED)
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
return EmployeeService(db).create_employee(payload)
@router.get("/{employee_id}", response_model=EmployeeRead)
def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
employee = EmployeeService(db).get_employee(employee_id)
if employee is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found")
return employee

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from fastapi import APIRouter
from sqlalchemy import text
from app.core.config import get_settings
from app.db.session import get_engine
router = APIRouter(prefix="/health")
@router.get("")
def health_check() -> dict[str, object]:
settings = get_settings()
database_ok = False
database_error = None
if settings.setup_completed:
try:
with get_engine().connect() as connection:
connection.execute(text("SELECT 1"))
database_ok = True
except Exception as exc: # pragma: no cover - runtime connectivity branch
database_error = str(exc)
return {
"status": "ok" if database_ok else "degraded",
"database": {
"configured": settings.setup_completed,
"ok": database_ok,
"error": database_error,
},
"redis": {"configured": bool(settings.redis_url), "enabled": bool(settings.redis_url)},
}

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.reimbursement import ReimbursementCreate, ReimbursementRead
from app.services.reimbursement import ReimbursementService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("", response_model=list[ReimbursementRead])
def list_reimbursements(db: DbSession) -> list[ReimbursementRead]:
return ReimbursementService(db).list_reimbursements()
@router.post("", response_model=ReimbursementRead, status_code=status.HTTP_201_CREATED)
def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> ReimbursementRead:
return ReimbursementService(db).create_reimbursement(payload)
@router.get("/{request_id}", response_model=ReimbursementRead)
def get_reimbursement(request_id: str, db: DbSession) -> ReimbursementRead:
request = ReimbursementService(db).get_reimbursement(request_id)
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Request not found")
return request

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])