diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af9d852 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# ── Application ────────────────────────────────────────────────────────────── +ENV=dev + +# ── Database ────────────────────────────────────────────────────────────────── +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/adiuva + +# ── Auth ────────────────────────────────────────────────────────────────────── +JWT_SECRET=replace-with-a-long-random-secret +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=30 + +# ── OpenAI ──────────────────────────────────────────────────────────────────── +OPENAI_API_KEY=sk-... + +# ── Stripe ──────────────────────────────────────────────────────────────────── +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# ── AWS / S3 ────────────────────────────────────────────────────────────────── +S3_BUCKET=adiuva-backups +S3_REGION=us-east-1 +AWS_ACCESS_KEY_ID=AKIA... +AWS_SECRET_ACCESS_KEY=... + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list parsed by Settings (override default if needed) +# CORS_ORIGINS=["app://.","http://localhost:3000"] diff --git a/BACKEND_PLAN.md b/BACKEND_PLAN.md index 4270611..9d88a2f 100644 --- a/BACKEND_PLAN.md +++ b/BACKEND_PLAN.md @@ -68,9 +68,9 @@ adiuva-api/ ## Step-by-Step Implementation -### Step 1 — Project scaffolding -- [ ] Initialize repo with the directory structure above -- [ ] Write `requirements.txt`: +### Step 1 — Project scaffolding ✅ +- [x] Initialize repo with the directory structure above +- [x] Write `requirements.txt`: ``` fastapi>=0.115.0 uvicorn[standard]>=0.34.0 @@ -91,11 +91,11 @@ adiuva-api/ pytest>=8.0.0 pytest-asyncio>=0.24.0 ``` -- [ ] Write `app/main.py`: FastAPI app with CORS (allow `app://`, `http://localhost:*`), lifespan (init DB pool, init agent registry), include all routers under `/api/v1` -- [ ] Write `app/config/settings.py`: `Settings(BaseSettings)` with fields: `DATABASE_URL`, `JWT_SECRET`, `JWT_ALGORITHM` (default HS256), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `S3_BUCKET`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `CORS_ORIGINS`, `ENV` (dev/prod) -- [ ] Write `Dockerfile`: Python 3.12 slim, multi-stage (builder + runtime), non-root user -- [ ] Write `docker-compose.yml`: app, postgres:16, optional redis -- [ ] Write `.env.example` +- [x] Write `app/main.py`: FastAPI app with CORS (allow `app://`, `http://localhost:*`), lifespan (init DB pool, init agent registry), include all routers under `/api/v1` +- [x] Write `app/config/settings.py`: `Settings(BaseSettings)` with fields: `DATABASE_URL`, `JWT_SECRET`, `JWT_ALGORITHM` (default HS256), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `S3_BUCKET`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `CORS_ORIGINS`, `ENV` (dev/prod) +- [x] Write `Dockerfile`: Python 3.12 slim, multi-stage (builder + runtime), non-root user +- [x] Write `docker-compose.yml`: app, postgres:16, optional redis +- [x] Write `.env.example` - **Outcome:** Runnable FastAPI skeleton (returns 404 on all routes). ### Step 2 — Pydantic schemas (API contracts) @@ -356,3 +356,4 @@ adiuva-api/ 4. **Type hints everywhere.** All functions have full type annotations. 5. **Test every agent.** Each chat agent has unit tests with mocked LLM responses. 6. **Structured logging.** JSON logs with request ID correlation. +7. **One step at a time.** Implement one numbered step per session. When the step is fully done, mark all its checkboxes as `[x]` in this file and commit with message `step N complete: `. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2de9a06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ── builder ────────────────────────────────────────────────────────────────── +FROM python:3.12-slim AS builder + +WORKDIR /build + +COPY requirements.txt . +RUN pip install --upgrade pip && \ + pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ── runtime ────────────────────────────────────────────────────────────────── +FROM python:3.12-slim AS runtime + +# Non-root user +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /install /usr/local + +# Copy application source +COPY app/ app/ + +# Ensure appuser owns the working directory +RUN chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agents/__init__.py b/app/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/middleware/__init__.py b/app/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/billing/__init__.py b/app/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..6a154f8 --- /dev/null +++ b/app/config/settings.py @@ -0,0 +1,31 @@ +from typing import Literal +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/adiuva" + JWT_SECRET: str = "change-me-in-production" + JWT_ALGORITHM: str = "HS256" + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + + STRIPE_SECRET_KEY: str = "" + STRIPE_WEBHOOK_SECRET: str = "" + + S3_BUCKET: str = "" + S3_REGION: str = "us-east-1" + AWS_ACCESS_KEY_ID: str = "" + AWS_SECRET_ACCESS_KEY: str = "" + + OPENAI_API_KEY: str = "" + + CORS_ORIGINS: list[str] = ["app://.", "http://localhost:3000", "http://localhost:5173"] + + ENV: Literal["dev", "prod"] = "dev" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0724d85 --- /dev/null +++ b/app/main.py @@ -0,0 +1,52 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config.settings import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: initialise DB connection pool and agent registry + from app.core.agent_registry import registry # noqa: F401 — triggers module load + import app.agents # noqa: F401 — triggers @registry.register decorators + + yield + + # Shutdown: nothing to clean up for now + + +def create_app() -> FastAPI: + app = FastAPI( + title="Adiuva Cloud API", + version="0.1.0", + docs_url="/docs" if settings.ENV == "dev" else None, + redoc_url=None, + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Routers (registered when implemented) + # from app.api.routes import auth, chat, plans, backup, billing + # app.include_router(auth.router, prefix="/api/v1") + # app.include_router(chat.router, prefix="/api/v1") + # app.include_router(plans.router, prefix="/api/v1") + # app.include_router(backup.router, prefix="/api/v1") + # app.include_router(billing.router, prefix="/api/v1") + + @app.get("/api/v1/health", tags=["health"]) + async def health() -> dict: + return {"status": "ok", "version": app.version} + + return app + + +app = create_app() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d1316b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +version: "3.9" + +services: + app: + build: . + ports: + - "8000:8000" + env_file: + - .env + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: adiuva + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + # Optional Redis for future rate-limit or caching needs + # redis: + # image: redis:7-alpine + # restart: unless-stopped + +volumes: + postgres_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a7590c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +langchain>=0.3.0 +langchain-openai>=0.3.0 +pydantic>=2.10.0 +pydantic-settings>=2.7.0 +python-jose[cryptography]>=3.3.0 +stripe>=11.0.0 +boto3>=1.35.0 +slowapi>=0.1.9 +sqlalchemy>=2.0.0 +asyncpg>=0.30.0 +alembic>=1.14.0 +bcrypt>=4.2.0 +python-dotenv>=1.0.0 +httpx>=0.28.0 +websockets>=14.0 +pytest>=8.0.0 +pytest-asyncio>=0.24.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29