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__ = ["main"]

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"])

View File

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

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote_plus
from dotenv import set_key
from app.core.config import ROOT_DIR, Settings, refresh_settings
from app.db.session import configure_session_factory
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
ENV_FILE = ROOT_DIR / ".env"
ENV_EXAMPLE_FILE = ROOT_DIR / ".env.example"
def ensure_env_file() -> Path:
if ENV_FILE.exists():
return ENV_FILE
if ENV_EXAMPLE_FILE.exists():
ENV_FILE.write_text(ENV_EXAMPLE_FILE.read_text(encoding="utf-8"), encoding="utf-8")
else:
ENV_FILE.write_text("", encoding="utf-8")
return ENV_FILE
def build_database_url(payload: BootstrapSetupPayload) -> str:
username = quote_plus(payload.postgres_user)
password = quote_plus(payload.postgres_password)
return (
f"postgresql+psycopg://{username}:{password}"
f"@{payload.postgres_host}:{payload.postgres_port}/{payload.postgres_db}"
)
def build_bootstrap_state(settings: Settings) -> BootstrapStateRead:
return BootstrapStateRead(
initialized=settings.setup_completed,
company={
"name": settings.company_name,
"code": settings.company_code,
"admin_email": settings.admin_email,
},
web={"host": settings.web_host, "port": settings.web_port},
server={"host": settings.app_host, "port": settings.app_port},
database={
"driver": "postgresql",
"host": settings.postgres_host,
"port": settings.postgres_port,
"name": settings.postgres_db,
"username": settings.postgres_user,
"password_configured": bool(settings.postgres_password),
},
redis={"enabled": bool(settings.redis_url), "url": settings.redis_url or ""},
)
def persist_bootstrap_config(payload: BootstrapSetupPayload, settings: Settings) -> BootstrapStateRead:
env_file = ensure_env_file()
database_url = build_database_url(payload)
vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}"
updates = {
"SETUP_COMPLETED": "true",
"COMPANY_NAME": payload.company_name,
"COMPANY_CODE": payload.company_code,
"ADMIN_EMAIL": payload.admin_email or "",
"POSTGRES_HOST": payload.postgres_host,
"POSTGRES_PORT": str(payload.postgres_port),
"POSTGRES_DB": payload.postgres_db,
"POSTGRES_USER": payload.postgres_user,
"POSTGRES_PASSWORD": payload.postgres_password,
"DATABASE_URL": database_url,
"REDIS_URL": payload.redis_url or "",
"VITE_API_BASE_URL": vite_api_base_url,
}
for key, value in updates.items():
set_key(str(env_file), key, value, quote_mode="auto", encoding="utf-8")
refreshed_settings = refresh_settings(updates)
configure_session_factory()
return build_bootstrap_state(refreshed_settings)

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from functools import lru_cache
from os import environ
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
SERVER_DIR = Path(__file__).resolve().parents[3]
ROOT_DIR = SERVER_DIR.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(ROOT_DIR / ".env", SERVER_DIR / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
app_env: str = Field(default="local", alias="APP_ENV")
app_debug: bool = Field(default=True, alias="APP_DEBUG")
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
company_name: str = Field(default="", alias="COMPANY_NAME")
company_code: str = Field(default="", alias="COMPANY_CODE")
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="127.0.0.1", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="127.0.0.1", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT")
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
vite_api_base_url: str = Field(default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL")
@property
def resolved_database_url(self) -> str:
if self.database_url:
return self.database_url
return (
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
def refresh_settings(updated_values: dict[str, str]) -> Settings:
for key, value in updated_values.items():
environ[key] = value
get_settings.cache_clear()
return get_settings()

View File

@@ -0,0 +1 @@
__all__ = ["base", "session"]

View File

@@ -0,0 +1,6 @@
from app.db.base_class import Base
from app.models.approval import ApprovalRecord
from app.models.employee import Employee
from app.models.reimbursement import ReimbursementRequest
__all__ = ["Base", "Employee", "ReimbursementRequest", "ApprovalRecord"]

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def configure_session_factory() -> None:
global _engine, _session_factory
settings = get_settings()
if _engine is not None:
_engine.dispose()
_engine = create_engine(
settings.resolved_database_url,
echo=settings.sqlalchemy_echo,
pool_pre_ping=True,
)
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
def get_engine() -> Engine:
global _engine
if _engine is None:
configure_session_factory()
return _engine
def get_session_factory() -> sessionmaker[Session]:
global _session_factory
if _session_factory is None:
configure_session_factory()
return _session_factory

37
server/src/app/main.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.app_name,
debug=settings.app_debug,
version="0.1.0",
)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_v1_prefix)
@app.get("/", tags=["root"])
def root() -> dict[str, str]:
return {"message": f"{settings.app_name} is running"}
return app
app = create_app()

View File

@@ -0,0 +1,5 @@
from app.models.approval import ApprovalRecord
from app.models.employee import Employee
from app.models.reimbursement import ReimbursementRequest
__all__ = ["ApprovalRecord", "Employee", "ReimbursementRequest"]

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class ApprovalRecord(Base):
__tablename__ = "approval_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
request_id: Mapped[str] = mapped_column(ForeignKey("reimbursement_requests.id"), index=True)
approver_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True)
action: Mapped[str] = mapped_column(String(50), index=True)
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
request = relationship("ReimbursementRequest", back_populates="approval_records")

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class Employee(Base):
__tablename__ = "employees"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
department: Mapped[str] = mapped_column(String(100), index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
reimbursement_requests = relationship("ReimbursementRequest", back_populates="employee")

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class ReimbursementRequest(Base):
__tablename__ = "reimbursement_requests"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
request_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
employee_id: Mapped[str] = mapped_column(ForeignKey("employees.id"), index=True)
title: Mapped[str] = mapped_column(String(200))
category: Mapped[str] = mapped_column(String(50), index=True)
status: Mapped[str] = mapped_column(String(50), default="draft", index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
employee = relationship("Employee", back_populates="reimbursement_requests")
approval_records = relationship("ApprovalRecord", back_populates="request", cascade="all, delete-orphan")

View File

@@ -0,0 +1 @@
__all__ = ["employee", "reimbursement"]

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.employee import Employee
class EmployeeRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(self) -> list[Employee]:
return self.db.query(Employee).order_by(Employee.created_at.desc()).all()
def get(self, employee_id: str) -> Employee | None:
return self.db.query(Employee).filter(Employee.id == employee_id).first()
def create(self, employee: Employee) -> Employee:
self.db.add(employee)
self.db.commit()
self.db.refresh(employee)
return employee

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.reimbursement import ReimbursementRequest
class ReimbursementRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(self) -> list[ReimbursementRequest]:
return self.db.query(ReimbursementRequest).order_by(ReimbursementRequest.created_at.desc()).all()
def get(self, request_id: str) -> ReimbursementRequest | None:
return self.db.query(ReimbursementRequest).filter(ReimbursementRequest.id == request_id).first()
def create(self, request: ReimbursementRequest) -> ReimbursementRequest:
self.db.add(request)
self.db.commit()
self.db.refresh(request)
return request

View File

@@ -0,0 +1 @@
__all__ = ["employee", "reimbursement"]

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr, Field, field_validator
class BootstrapCompanyRead(BaseModel):
name: str
code: str
admin_email: str
class BootstrapConnectionRead(BaseModel):
host: str
port: int
class BootstrapDatabaseRead(BaseModel):
driver: str
host: str
port: int
name: str
username: str
password_configured: bool
class BootstrapCacheRead(BaseModel):
enabled: bool
url: str
class BootstrapStateRead(BaseModel):
initialized: bool
company: BootstrapCompanyRead
web: BootstrapConnectionRead
server: BootstrapConnectionRead
database: BootstrapDatabaseRead
redis: BootstrapCacheRead
class BootstrapSetupPayload(BaseModel):
company_name: str = Field(min_length=2, max_length=80)
company_code: str = Field(default="", max_length=32)
admin_email: EmailStr | None = None
postgres_host: str = Field(min_length=1, max_length=255)
postgres_port: int = Field(default=5432, ge=1, le=65535)
postgres_db: str = Field(min_length=1, max_length=128)
postgres_user: str = Field(min_length=1, max_length=128)
postgres_password: str = Field(min_length=1, max_length=255)
redis_url: str | None = Field(default=None, max_length=255)
@field_validator(
"company_name",
"company_code",
"postgres_host",
"postgres_db",
"postgres_user",
"postgres_password",
"redis_url",
mode="before",
)
@classmethod
def strip_string(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
class EmployeeCreate(BaseModel):
employee_no: str
name: str
department: str
email: EmailStr
class EmployeeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
employee_no: str
name: str
department: str
email: EmailStr
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class ReimbursementCreate(BaseModel):
request_no: str
employee_id: str
title: str
category: str
amount: Decimal
reason: str | None = None
class ReimbursementRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
request_no: str
employee_id: str
title: str
category: str
status: str
amount: Decimal
reason: str | None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1 @@
__all__ = ["employee", "reimbursement"]

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.employee import Employee
from app.repositories.employee import EmployeeRepository
from app.schemas.employee import EmployeeCreate
class EmployeeService:
def __init__(self, db: Session) -> None:
self.repository = EmployeeRepository(db)
def list_employees(self) -> list[Employee]:
return self.repository.list()
def get_employee(self, employee_id: str) -> Employee | None:
return self.repository.get(employee_id)
def create_employee(self, payload: EmployeeCreate) -> Employee:
employee = Employee(**payload.model_dump())
return self.repository.create(employee)

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.reimbursement import ReimbursementRequest
from app.repositories.reimbursement import ReimbursementRepository
from app.schemas.reimbursement import ReimbursementCreate
class ReimbursementService:
def __init__(self, db: Session) -> None:
self.repository = ReimbursementRepository(db)
def list_reimbursements(self) -> list[ReimbursementRequest]:
return self.repository.list()
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
return self.repository.get(request_id)
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
request = ReimbursementRequest(**payload.model_dump(), status="draft")
return self.repository.create(request)