Skip to content

Commit 89e2d80

Browse files
committed
add: auth, rate limiting, tests (backend+frontend), CI/CD, remove demo URLs
1 parent a3ae885 commit 89e2d80

18 files changed

Lines changed: 9267 additions & 542 deletions

.github/workflows/ci.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
jobs:
10+
backend:
11+
runs-on: ubuntu-latest
12+
defaults:
13+
run:
14+
working-directory: backend
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python 3.11
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.11"
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install -r requirements.txt
28+
29+
- name: Run tests
30+
run: |
31+
python -m pytest tests/ -v --no-header -p no:warnings
32+
env:
33+
DATABASE_URL: postgresql+asyncpg://localhost:5432/leadforge
34+
GOOGLE_MAPS_API_KEY: test
35+
GOOGLE_SERVICE_ACCOUNT_JSON: test
36+
37+
frontend:
38+
runs-on: ubuntu-latest
39+
defaults:
40+
run:
41+
working-directory: frontend
42+
43+
steps:
44+
- uses: actions/checkout@v4
45+
46+
- name: Set up Node.js
47+
uses: actions/setup-node@v4
48+
with:
49+
node-version: "18"
50+
51+
- name: Install dependencies
52+
run: npm ci
53+
54+
- name: Run tests
55+
run: npm test

README.md

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44
<img src="https://img.shields.io/badge/FastAPI-0.115-green?style=flat-square&logo=fastapi" alt="FastAPI">
55
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License">
66
<img src="https://img.shields.io/badge/status-active-success?style=flat-square" alt="Status">
7-
<img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square" alt="Coverage">
7+
88

99
<h1>LeadForge</h1>
1010
<p><strong>Find businesses without websites. Generate personalized outreach. Automate via email.</strong></p>
1111
<p>A complete lead generation platform that finds local businesses lacking an online presence, crafts targeted cold emails using smart templates, and automates delivery through Google Sheets + Apps Script.</p>
1212

1313
<p>
1414
<a href="#features">Features</a> •
15-
<a href="#demo">Demo</a> •
1615
<a href="#quick-start">Quick Start</a> •
1716
<a href="#deployment">Deployment</a> •
1817
<a href="#usage">Usage</a> •
@@ -32,15 +31,7 @@
3231
- **Apps Script Automation** — Copy-paste script sends batch emails from your Gmail (8 per 20 min, designed with opt-out footer)
3332
- **Duplicate Prevention** — Dedup by Google Place ID across all campaigns
3433
- **Background Processing** — Search runs asynchronously; poll campaign status from the dashboard
35-
- **Live Deployment** — Backend on Railway, frontend on Vercel, auto-deploys from GitHub
36-
37-
## Demo
38-
39-
| Service | URL | Status |
40-
|---------|-----|--------|
41-
| Frontend | [frontend-alpha-teal-72.vercel.app](https://frontend-alpha-teal-72.vercel.app) | ✅ Live |
42-
| Backend | [leadforge-production-126b.up.railway.app](https://leadforge-production-126b.up.railway.app) | ✅ Live |
43-
| Health | [leadforge-production-126b.up.railway.app/api/health](https://leadforge-production-126b.up.railway.app/api/health) | `{"status":"ok"}` |
34+
- **CI/CD** — GitHub Actions runs backend + frontend tests on every push
4435

4536
## Architecture
4637

@@ -86,6 +77,8 @@ Search Form → POST /api/search → Grid Search (Places API)
8677
| **APIs** | Google Places API (New), Google Sheets API v4 |
8778
| **Automation** | Google Apps Script (Gmail send, quota-aware) |
8879
| **Infrastructure** | Railway.app, Vercel, Docker Compose (local dev) |
80+
| **CI/CD** | GitHub Actions (backend + frontend tests on push) |
81+
| **Testing** | pytest (backend), Jest + RTL (frontend) |
8982

9083
## Quick Start
9184

@@ -157,7 +150,7 @@ Set `NEXT_PUBLIC_API_URL` to your Railway backend URL (e.g. `https://leadforge-p
157150
1. Push to GitHub
158151
2. Create a Railway project → "Deploy from GitHub repo" → select `abeermeer/LeadForge`
159152
3. Add **PostgreSQL** plugin (DATABASE_URL auto-injected)
160-
4. Set remaining env vars: `GOOGLE_MAPS_API_KEY`, `GOOGLE_SERVICE_ACCOUNT_JSON`
153+
4. Set remaining env vars: `GOOGLE_MAPS_API_KEY`, `GOOGLE_SERVICE_ACCOUNT_JSON`, `API_KEY`
161154
5. Railway builds using the `Dockerfile` at repo root
162155

163156
The included `railway.json` configures Dockerfile builder with 1 replica and ON_FAILURE restart policy.
@@ -200,10 +193,12 @@ Emails send in batches of 8 every 20 minutes, respecting Gmail's 100/day quota (
200193

201194
## API Reference
202195

196+
All endpoints (except `/api/health`) require `X-API-Key` header if `API_KEY` is set. POST `/api/search` is rate-limited to 5 requests per minute.
197+
203198
| Method | Path | Description |
204199
|--------|------|-------------|
205-
| `GET` | `/api/health` | Health check |
206-
| `POST` | `/api/search` | Create search campaign (runs in background) |
200+
| `GET` | `/api/health` | Health check (no auth required) |
201+
| `POST` | `/api/search` | Create search campaign (rate-limited: 5/min) |
207202
| `GET` | `/api/search/{id}` | Poll campaign status |
208203
| `GET` | `/api/campaigns/{id}/leads` | List leads for campaign |
209204
| `PATCH` | `/api/leads/{id}` | Update lead email fields |
@@ -234,6 +229,7 @@ Allowlisted fields: `email`, `email_subject`, `email_body`, `angle_used`, `email
234229
|----------|-------------|----------|
235230
| `GOOGLE_MAPS_API_KEY` | Google Places API (New) key ||
236231
| `GOOGLE_SERVICE_ACCOUNT_JSON` | Service account key (base64 or raw JSON) ||
232+
| `API_KEY` | API key for auth (set to enable) ||
237233
| `DATABASE_URL` | PostgreSQL with async driver ||
238234
| `NEXT_PUBLIC_API_URL` | Backend URL (frontend only) ||
239235

@@ -244,6 +240,7 @@ LeadForge/
244240
├── backend/
245241
│ ├── main.py # FastAPI entry point
246242
│ ├── config.py # Environment config
243+
│ ├── auth.py # API key authentication
247244
│ ├── database.py # SQLAlchemy async engine
248245
│ ├── models/
249246
│ │ ├── campaign.py # Campaign table model

backend/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
GOOGLE_MAPS_API_KEY=your_places_api_key_here
22
GOOGLE_SERVICE_ACCOUNT_JSON=your_base64_or_raw_json_here
3+
API_KEY=your_api_key_here
34
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/leadforge

backend/auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import os
2+
import logging
3+
from fastapi import Security, HTTPException, status
4+
from fastapi.security import APIKeyHeader
5+
from .config import API_KEY
6+
7+
logger = logging.getLogger(__name__)
8+
9+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
10+
11+
async def verify_api_key(api_key: str = Security(api_key_header)):
12+
if not API_KEY:
13+
return True
14+
if not api_key or api_key != API_KEY:
15+
raise HTTPException(
16+
status_code=status.HTTP_401_UNAUTHORIZED,
17+
detail="Invalid or missing API key",
18+
)
19+
return True

backend/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "")
77
GOOGLE_SERVICE_ACCOUNT_JSON = os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON", "")
8+
API_KEY = os.getenv("API_KEY", "")
89
_db_url = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/leadforge")
910
DATABASE_URL = _db_url.replace("postgresql://", "postgresql+asyncpg://", 1) if _db_url.startswith("postgresql://") and not _db_url.startswith("postgresql+asyncpg://") else _db_url
1011
MAX_RESULTS_PER_QUERY = 60

backend/main.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,39 @@
11
import logging
2-
from fastapi import FastAPI
2+
from fastapi import FastAPI, Depends
33
from fastapi.middleware.cors import CORSMiddleware
44
from contextlib import asynccontextmanager
5+
from slowapi import Limiter, _rate_limit_exceeded_handler
6+
from slowapi.util import get_remote_address
7+
from slowapi.errors import RateLimitExceeded
58
from sqlalchemy import text
69
from .database import init_db, async_session
710
from .routers import search, leads, export
11+
from .auth import verify_api_key
812

913
logger = logging.getLogger(__name__)
1014

15+
limiter = Limiter(key_func=get_remote_address)
16+
1117
@asynccontextmanager
1218
async def lifespan(app: FastAPI):
1319
await init_db()
1420
yield
1521

1622
app = FastAPI(title="LeadForge", lifespan=lifespan)
1723

24+
app.state.limiter = limiter
25+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
26+
1827
app.add_middleware(
1928
CORSMiddleware,
2029
allow_origins=["*"],
2130
allow_methods=["*"],
2231
allow_headers=["*"],
2332
)
2433

25-
app.include_router(search.router, prefix="/api", tags=["search"])
26-
app.include_router(leads.router, prefix="/api", tags=["leads"])
27-
app.include_router(export.router, prefix="/api", tags=["export"])
34+
app.include_router(search.router, prefix="/api", tags=["search"], dependencies=[Depends(verify_api_key)])
35+
app.include_router(leads.router, prefix="/api", tags=["leads"], dependencies=[Depends(verify_api_key)])
36+
app.include_router(export.router, prefix="/api", tags=["export"], dependencies=[Depends(verify_api_key)])
2837

2938
@app.get("/api/health")
3039
async def health():

backend/requirements.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ aiohttp==3.11.11
66
google-auth==2.37.0
77
google-api-python-client==2.159.0
88
python-dotenv==1.0.1
9-
redis==5.2.1
10-
rq==2.1.0
119
pydantic==2.10.4
10+
slowapi==0.1.9
11+
pytest==8.3.4
12+
pytest-asyncio==0.25.3
13+
httpx==0.28.1
14+
pytest-httpx==0.35.0

backend/routers/search.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
1+
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Request
22
from pydantic import BaseModel, Field
33
from sqlalchemy.ext.asyncio import AsyncSession
4+
from slowapi import Limiter
5+
from slowapi.util import get_remote_address
46
from ..database import get_session
57
from ..models.campaign import Campaign, CampaignStatus
68
from ..workers.grid_search import run_grid_search
79

810
router = APIRouter()
11+
limiter = Limiter(key_func=get_remote_address)
912

1013
class SearchRequest(BaseModel):
1114
query: str
@@ -16,8 +19,10 @@ class SearchRequest(BaseModel):
1619
min_rating: float | None = None
1720

1821
@router.post("/search")
22+
@limiter.limit("5/minute")
1923
async def create_search(
2024
req: SearchRequest,
25+
request: Request,
2126
background_tasks: BackgroundTasks,
2227
db: AsyncSession = Depends(get_session),
2328
):

backend/tests/__init__.py

Whitespace-only changes.

backend/tests/conftest.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import os
2+
import sys
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
6+
7+
db_session_mock = MagicMock()
8+
db_session_mock.__aenter__.return_value = db_session_mock
9+
db_session_mock.__aexit__.return_value = None
10+
11+
_async_session_patcher = patch("backend.database.async_session", return_value=db_session_mock)
12+
_get_session_patcher = patch("backend.database.get_session", return_value=db_session_mock)
13+
_init_db_patcher = patch("backend.database.init_db", return_value=None)
14+
15+
_async_session_patcher.start()
16+
_get_session_patcher.start()
17+
_init_db_patcher.start()
18+
19+
import pytest
20+
from fastapi import FastAPI, Depends
21+
from fastapi.middleware.cors import CORSMiddleware
22+
23+
from backend.routers.search import router as search_router
24+
from backend.routers.leads import router as leads_router
25+
from backend.routers.export import router as export_router
26+
27+
@pytest.fixture
28+
def app():
29+
app = FastAPI(title="LeadForge-Test")
30+
31+
app.add_middleware(
32+
CORSMiddleware,
33+
allow_origins=["*"],
34+
allow_methods=["*"],
35+
allow_headers=["*"],
36+
)
37+
38+
app.include_router(search_router, prefix="/api")
39+
app.include_router(leads_router, prefix="/api")
40+
app.include_router(export_router, prefix="/api")
41+
42+
@app.get("/api/health")
43+
async def health():
44+
return {"status": "ok", "database": "disconnected"}
45+
46+
return app
47+
48+
@pytest.fixture
49+
def app_with_key():
50+
os.environ["API_KEY"] = "test-key-123"
51+
52+
import importlib
53+
import backend.config
54+
importlib.reload(backend.config)
55+
56+
from backend.auth import verify_api_key
57+
58+
test_app = FastAPI(title="LeadForge-Test-Auth")
59+
60+
test_app.add_middleware(
61+
CORSMiddleware,
62+
allow_origins=["*"],
63+
allow_methods=["*"],
64+
allow_headers=["*"],
65+
)
66+
67+
test_app.include_router(search_router, prefix="/api", dependencies=[Depends(verify_api_key)])
68+
test_app.include_router(leads_router, prefix="/api", dependencies=[Depends(verify_api_key)])
69+
test_app.include_router(export_router, prefix="/api", dependencies=[Depends(verify_api_key)])
70+
71+
@test_app.get("/api/health")
72+
async def health():
73+
return {"status": "ok", "database": "disconnected"}
74+
75+
yield test_app
76+
77+
del os.environ["API_KEY"]
78+
importlib.reload(backend.config)

0 commit comments

Comments
 (0)