Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ POSTGRES_DB=loanflow
DATABASE_URL=postgresql+psycopg://loanflow:loanflow@db:5432/loanflow

# --- API ---
# dev | test | local skip the strong-secret check below; anything else requires
# a real JWT_SECRET (>=32 chars) or the API refuses to start.
ENVIRONMENT=dev
# Generate: python -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_SECRET=dev-only-change-me
JWT_ALGORITHM=HS256
JWT_ACCESS_TTL_MINUTES=15
JWT_REFRESH_TTL_DAYS=14
CORS_ORIGINS=http://localhost:5173
Expand Down
230 changes: 181 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,70 +1,202 @@
# LoanFlow

A maker–checker loan-underwriting workbench. Operations logs an incoming loan file,
Underwriting runs four maker–checker verifications (credit, KYC, payment eligibility,
tax return), the file is marked **fund ready to release**, and a housekeeping job
purges it 30 days later.

Built to learn **React + TypeScript** (frontend) and **FastAPI + PostgreSQL**
(backend) end to end, deployed with a CI/CD pipeline, and to showcase a
guard-railed [Claude Code workflow](docs/ai-workflow.md).

Full plan: [`docs/ROADMAP.md`](docs/ROADMAP.md) · Architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)

## Stack
[![CI](https://github.com/sujitd7/loanflow/actions/workflows/ci.yml/badge.svg)](https://github.com/sujitd7/loanflow/actions/workflows/ci.yml)
 ·  FastAPI · React + TypeScript · PostgreSQL · Docker · GitHub Actions

A **maker–checker loan-underwriting workbench**. Operations logs an incoming loan
file; Underwriting runs four independent maker–checker verifications (credit, KYC,
payment eligibility, tax return); once all four pass the file is marked
**fund-ready-to-release**; a housekeeping job purges it 30 days later, leaving only
a PII-free audit summary.

I'm building it in the open to practise **React + TypeScript** and
**FastAPI + PostgreSQL** end to end — with the patterns a real system-of-record
needs (RBAC, an explicit state machine, optimistic locking, an append-only audit
trail, expand/contract migrations) rather than CRUD — and to run a deliberate,
**guard-railed [Claude Code](docs/ai-workflow.md) workflow** on top of it.

> **Status:** early. Backend foundation (identity + RBAC) is done and CI-green;
> the loan-file domain and the React UI are next. See
> [Roadmap progress](#roadmap-progress).

Full plan: [`docs/ROADMAP.md`](docs/ROADMAP.md) ·
Architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) ·
State machine: [`docs/STATE_MACHINE.md`](docs/STATE_MACHINE.md) ·
ADRs: [`docs/adr/`](docs/adr/)

---

## Architecture

Four small services, one system of record. The `api`, `web`, and `worker` images
are identical in dev and prod; only the Postgres and object storage differ.

```mermaid
flowchart LR
Browser -->|HTTPS| Web["web — React SPA (nginx)"]
Web -->|/api| API["api — FastAPI (stateless)"]
API -->|SQL| DB[("PostgreSQL 16")]
API -->|uploads| Store[("Document storage")]
Worker["worker — APScheduler"] -->|advisory-locked jobs| DB
Worker -->|purge on schedule| Store

subgraph reqpath ["Inside the API: router → service → state machine"]
direction TB
R["router — HTTP only"] --> S["service — business logic"]
S --> T["state_machine.transition() — the only status writer"]
S --> Q["queries"]
end
API -.-> R
```

| Layer | Choice |
|-----------|-----------------------------------------------|
| Frontend | React 18, TypeScript, Vite, TanStack Query |
| Backend | FastAPI, SQLAlchemy 2, Alembic |
| Database | PostgreSQL 16 |
| Jobs | APScheduler worker process |
| Deploy | Docker → Fly.io / single VPS |
| CI/CD | GitHub Actions → GHCR |
**Backend layering** (`api/app/`)

| Layer | Responsibility |
|--------------|----------------|
| `routers/` | HTTP only — validate input, call one service, serialize a schema. RBAC declared here via `require_roles(...)`. |
| `schemas/` | Pydantic v2 request/response models — no raw dicts cross the boundary. |
| `services/` | Business logic. Every status change goes through `state_machine.transition()`; a hook flags raw `.status =` writes. |
| `models/` | SQLAlchemy 2.0 ORM (`Mapped[...]`), Alembic migrations. |
| `db.py` | Engine + `get_db` — one session per request, commit on success / rollback on error. |
| `deps.py` | Auth + RBAC dependencies. |

**Domain rules worth a look**

- **State machine** — loan files move `DRAFT → SUBMITTED → IN_REVIEW →
FUND_READY_TO_RELEASE → PURGED`; review tasks have a
`PENDING_MAKER ⇄ PENDING_CHECKER → COMPLETED` cycle with a rejection loop. One
function owns every transition; a hook blocks raw `.status =` writes.
- **Maker ≠ checker** — a reviewer can never approve their own task
(DB check constraint + service guard).
- **Optimistic locking** — task writes carry a `version`; a stale write is a
`409`, the client refetches and retries.
- **Audit** — every transition appends one immutable `task_events` row.
- **Housekeeping** — the purge job is idempotent and wrapped in
`pg_try_advisory_lock` so replicas never double-run it.
- **Migrations** are expand/contract, so a code rollback never needs a DB rollback.

**RBAC matrix**

| Role | Team | Can |
|--------------|------|-----|
| `OPS_MAKER` | OPS | create / submit loan files, upload documents |
| `OPS_CHECKER`| OPS | review the intake step |
| `UW_MAKER` | UW | perform a review task (maker side) |
| `UW_CHECKER` | UW | approve / reject a review task (checker side) |
| `ADMIN` | — | reassign tasks, trigger housekeeping, read everything |

---

## Roadmap progress

One phase per PR, tests green before the next. Full detail in
[`docs/ROADMAP.md`](docs/ROADMAP.md); current state in
[`docs/STATUS.md`](docs/STATUS.md).

| Phase | Scope | State |
|-------|-------|-------|
| **P0** | Monorepo, `docker-compose`, `/health` API + Vite app, worker skeleton, CI, `.claude/` workflow config | ✅ done |
| **P1** | Identity: `users` + `refresh_tokens`, Argon2, JWT login / rotating refresh / logout / me, `Role` enum + `require_roles(...)`, 35 tests | ✅ done · [PR #1](https://github.com/sujitd7/loanflow/pull/1), CI green |
| **P2** | Loan-file intake + atomic 4-task generation, document upload, list/detail with no N+1 | ▶ next |
| **P3** | Maker–checker flow, `state_machine.transition()`, `version` conflicts, audit events | ☐ |
| **P4** | Completion + housekeeping purge job (`freezegun` tests, advisory lock) | ☐ |
| **P5** | React foundation + core flows — auth context, Axios refresh interceptor, TanStack Query, submission wizard, My Tasks, review drawer, MSW tests | ☐ |
| **P6** | Dashboards — aggregation endpoints, Recharts funnel / aging / per-member | ☐ |
| **P7** | Hardening — Playwright E2E, rate limiting, JSON logging, deterministic seed data, `/security-review` | ☐ |
| **P8** | Deploy — multi-stage Dockerfiles, GHCR, release migrations, smoke test, rollback | ☐ |
| **P9** | Showcase — hero GIF, live demo, architecture write-up, Loom | ☐ |

**Highlight from P1:** the `security-reviewer` subagent caught a real HIGH-severity
bug before merge — refresh-token reuse-detection was writing the revocation into
the request-scoped session, which then got rolled back by the 401 it raised, so
in production a stolen refresh token was effectively unrevocable. Fixed by
committing that side effect in its own unit of work, with a regression test using
real per-request sessions. (Details in [PR #1](https://github.com/sujitd7/loanflow/pull/1).)

---

## Built with a guard-railed Claude Code workflow

This repo is also a worked example of **AI-assisted development with real
guard-rails** — not "pasted from a chatbot". Everything below is committed under
[`.claude/`](.claude/) and [`scripts/hooks/`](scripts/hooks/); the full write-up
is [`docs/ai-workflow.md`](docs/ai-workflow.md).

- **`CLAUDE.md`** — a project brief (stack, conventions, the "never write
`.status` directly" rule, the RBAC matrix) loaded into every session, so the
assistant works to *this* codebase's standards.
- **Hooks** — deterministic checks the harness runs, not the model:
auto-format/lint on every write, **block dangerous shell commands**
(`rm -rf`, unforced force-push, prod `psql`, `fly deploy`), flag raw status
writes, and optionally run the test suite on stop so a task can't "finish" red.
- **Subagents** (`.claude/agents/`) — seven focused roles used per feature:
`api-designer`, `db-migrator`, `workflow-modeler`, `security-reviewer`,
`frontend-builder`, `test-writer`, `pr-writer`.
- **Skills** (`.claude/skills/`) — repo-specific recipes: `add-endpoint`,
`add-migration`, `add-scheduled-job`, `add-page`, `seed-demo-data`, `deploy`.
- **Roadmap-driven loop** — `/loop` works `docs/ROADMAP.md` one checkbox at a
time: write the tests first, make them pass, run the suite, tick the box,
commit.

**Delegated:** boilerplate routers/schemas/fixtures/migrations, test scaffolding,
formatting, PR text. **Kept by me:** the domain model and state machine, the RBAC
design, architectural calls (ADRs), migration discipline, and every review
decision.

---

## Run it locally

With Docker Desktop:
With Docker:

```bash
cp .env.example .env
docker compose up --build
```

**No working Docker?** See [`docs/LOCAL_DEV.md`](docs/LOCAL_DEV.md) — the stack runs
natively with a venv + `npm run dev` against any Postgres (a free Neon database
works). Tests fall back to SQLite with no setup.
**No working Docker?** See [`docs/LOCAL_DEV.md`](docs/LOCAL_DEV.md) — the stack
runs natively with a Python venv + `npm run dev` against any Postgres (a free
Neon database works). The backend test suite falls back to in-memory SQLite with
zero setup.

| Service | URL |
|------------------|------------------------------|
| Web (Vite) | http://localhost:5173 |
| API (FastAPI) | http://localhost:8000 |
| API docs | http://localhost:8000/docs |
| API health | http://localhost:8000/health |
| Postgres | localhost:5432 |
| Service | URL |
|---------------|------------------------------|
| Web (Vite) | http://localhost:5173 |
| API (FastAPI) | http://localhost:8000 |
| API docs | http://localhost:8000/docs |
| API health | http://localhost:8000/health |
| Postgres | localhost:5432 |

## Repo layout
## Development

```
api/ FastAPI service + Alembic migrations + pytest
worker/ APScheduler job runner (housekeeping / purge)
web/ React + TypeScript SPA (Vite)
infra/ deployment config (added in P8)
docs/ roadmap, architecture, ADRs, AI-workflow writeup
.claude/ committed Claude Code config: hooks, subagents, skills
scripts/ hook scripts and helpers
```bash
make help # list tasks
make up / make down
make test # api (pytest) + web (vitest)
make fmt / make lint
make migrate m="add loan_files"
```

## Development
## Repo layout

```bash
make help # list tasks
make up / make down
make test # api + web tests
make fmt / make lint
make migrate m="add users table"
```
CLAUDE.md project brief loaded into every Claude Code session
api/ FastAPI service + SQLAlchemy models + Alembic migrations + pytest
worker/ APScheduler job runner (housekeeping / purge)
web/ React + TypeScript SPA (Vite) — foundation lands in P5
infra/ deployment config — added in P8
docs/ roadmap, architecture, state machine, ADRs, AI-workflow write-up
.claude/ committed Claude Code config: hooks, subagents, skills
scripts/ hook scripts and helpers
```

## Status
## Tech stack

Phase **P0 — Foundation & tooling**. See [`docs/STATUS.md`](docs/STATUS.md).
| Layer | Choice |
|-----------|--------|
| Frontend | React 18, TypeScript, Vite, TanStack Query, react-hook-form + zod |
| Backend | FastAPI, SQLAlchemy 2.0, Alembic, Pydantic v2, psycopg 3, Argon2 + JWT |
| Database | PostgreSQL 16 |
| Jobs | APScheduler in a dedicated worker process ([ADR 0002](docs/adr/0002-apscheduler-over-celery.md)) |
| Deploy | Docker images → Fly.io / single VPS (planned, P8) |
| CI/CD | GitHub Actions → GHCR |
66 changes: 66 additions & 0 deletions api/alembic/versions/bd693f8a6bb0_add_users_and_refresh_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""add users and refresh_tokens

Revision ID: bd693f8a6bb0
Revises:
Create Date: 2026-08-28

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "bd693f8a6bb0"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

role_enum = sa.Enum("OPS_MAKER", "OPS_CHECKER", "UW_MAKER", "UW_CHECKER", "ADMIN", name="role")
team_enum = sa.Enum("OPS", "UW", name="team")


def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("full_name", sa.String(length=200), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", role_enum, nullable=False),
sa.Column("team", team_enum, nullable=True),
sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)

op.create_table(
"refresh_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("jti", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index(op.f("ix_refresh_tokens_jti"), "refresh_tokens", ["jti"], unique=True)
op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False)


def downgrade() -> None:
op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens")
op.drop_index(op.f("ix_refresh_tokens_jti"), table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
bind = op.get_bind()
team_enum.drop(bind, checkfirst=True)
role_enum.drop(bind, checkfirst=True)
21 changes: 20 additions & 1 deletion api/app/config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

_INSECURE_JWT_SECRET = "dev-only-change-me" # noqa: S105 (placeholder, not a credential)
_LOCAL_ENVS = {"dev", "test", "local"}


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")

environment: str = "dev"

database_url: str = "postgresql+psycopg://loanflow:loanflow@db:5432/loanflow"

jwt_secret: str = "dev-only-change-me"
jwt_secret: str = _INSECURE_JWT_SECRET
jwt_algorithm: str = "HS256"
jwt_access_ttl_minutes: int = 15
jwt_refresh_ttl_days: int = 14

Expand All @@ -17,5 +24,17 @@ class Settings(BaseSettings):
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]

@model_validator(mode="after")
def _reject_insecure_secret_outside_local(self) -> "Settings":
if self.environment.lower() in _LOCAL_ENVS:
return self
if self.jwt_secret == _INSECURE_JWT_SECRET or len(self.jwt_secret) < 32:
raise ValueError(
"JWT_SECRET must be set to a strong value (>=32 chars) when "
f"ENVIRONMENT is {self.environment!r}. Generate one with "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.'
)
return self


settings = Settings()
Loading
Loading