A chat web app where signed-in users ask questions about revenue/income of US public companies, grounded in a Postgres database via OpenAI tool-calling (no hallucinated numbers), with SSE streaming, visible tool-call rendering, Redis-backed usage limits, and conversation management.
- Node.js 20+
- pnpm 9+ (
corepack enablewill install the pinned version frompackage.json'spackageManagerfield) - Docker (for Postgres + Redis via
docker-compose.yml) - An OpenAI API key
-
Install dependencies:
pnpm install
-
Copy the env file and fill in your OpenAI key:
cp apps/backend/.env.example apps/backend/.env
Edit
apps/backend/.envand setOPENAI_API_KEY. Defaults for everything else (DATABASE_URL,REDIS_URL,JWT_SECRET,OPENAI_MODEL=gpt-4.1-nano,USAGE_LIMIT_USD=1,USAGE_RESET_SECONDS=3600) work out of the box with the Docker Compose services below. -
Bring up Postgres and Redis:
docker compose up -d
-
Run database migrations, then load the provided financial data:
pnpm --filter @financial-chat/backend migrate pnpm --filter @financial-chat/backend load-data
load-datarunsdata/financial_data.sqldirectly against Postgres (it drops and recreates thefinancial_datatable from the dump, unmodified) — safe to re-run at any time. -
Start the backend and frontend (in separate terminals):
pnpm dev:backend # http://localhost:4000 pnpm dev:frontend # http://localhost:5173
-
Open
http://localhost:5173, register an account, and start chatting.
pnpm test # every workspace
pnpm --filter @financial-chat/backend test # backend only (requires docker compose up -d)
pnpm --filter @financial-chat/frontend test # frontend only
pnpm --filter @financial-chat/shared test # shared types onlyBackend tests hit real Postgres and Redis instances via DATABASE_URL/REDIS_URL, so docker compose up -d must be running first.
Two env vars in apps/backend/.env control the per-window spend gate (Redis key usage:{userId}:{windowKey}, windowKey = floor(now / 1000 / USAGE_RESET_SECONDS)):
USAGE_LIMIT_USD(default1) — dollar ceiling per window before requests are rejected with a friendly SSEerrorevent.USAGE_RESET_SECONDS(default3600) — window length in seconds; windows are clock-aligned, not per-user-rolling.
For fast manual testing of the limit-exceeded flow (assignment scenario S4), temporarily set:
USAGE_LIMIT_USD=0.001
USAGE_RESET_SECONDS=180
then restart the backend, send a couple of chat messages, and observe the rejection — then restore the defaults.
OPENAI_MODEL defaults to gpt-4.1-nano (cheapest tier, function-calling capable). gpt-4o-mini is a documented alternative — set OPENAI_MODEL=gpt-4o-mini in apps/backend/.env to switch; pricing for both is hardcoded in apps/backend/src/pricing/pricing-table.ts.
/apps/backend — Express + TypeScript + Drizzle ORM + ioredis + openai + jsonwebtoken + bcrypt + tiktoken
/apps/frontend — React + TypeScript (Vite) + Tailwind + shadcn/ui + recharts + react-router-dom + React Query
/packages/shared — shared types (SSE event shapes, message part shapes)
docker-compose.yml — Postgres 16 + Redis 7
Two-tool pattern for grounding: query_financials (whitelisted Drizzle query builder, no LLM-authored SQL) and render_answer (structured { text, table?, chart? } output).
- No CSRF token (sameSite=lax cookie only) — acceptable for a local single-origin dev app.
- No login rate limiting / brute-force protection.
- No lifetime/historical usage view beyond the current-window badge (Postgres stores
cost_usdper message if this is ever needed later). - Fixed clock-aligned usage windows, not per-user-rolling.