An educational full-stack capstone project that teaches how a simple preference-learning algorithm works by letting users train one in real time. Users rate travel photos from around the world, and a feedback loop adjusts their country preferences — visualized live on a dashboard.
- Domain:
buildbetteralgorithms.com— registered for the rebuild. (v1 ran on the now-lapsedbuildingbetteralgorithms.com.) - Status: Active refactor — DEV environment deployed; backend (Phase 1) is next.
The app walks a user through a complete machine-feedback loop:
- Sign up / log in — animated landing page (HomePage) with register + login forms.
- Set preferences — pick like/dislike for 12 cities. This seeds a 12-element
preference array (liked =
50, disliked =5, scale0–100). - Travel — the app fetches real photos from the Google Places API, weighted by the preference array. The user likes (single click) or dislikes (double click) each photo and submits.
- Learn — submitted feedback is run through the algorithm, which nudges each
country's score up or down by a fixed learning increment (
±5, clamped0–100). - Dashboard — the updated preference array is rendered as a GeoChart, pie chart, and column chart using Google Charts.
There is a hard usage limit of 4 image batches (32 photos) per session to stay within Google Places API quota.
backend/backend/algorithm.py — update_preferences(data):
- Input: first 12 elements are the current preferences, followed by feedback entries
formatted
choice:location(choice=1like /0dislike). - A liked country gains
+5; a disliked country loses-5; values are clamped to0–100(and bounced off the rails:100→95,0→5) so no country is ever fully excluded from the weighted random draw.
backend/backend/places.py — run_process(preferences):
- Normalizes the preference array into weights, then for 8 iterations does a weighted
random pick of a city and fetches a nearby
tourist_attractionphoto via Google Places. Returns strings formattedlocationIndex:photoUrl.
Note:
algorithm-testing/contains earlier standalone prototypes of these two files (a0.0–1.0scale variant). They are experiments, not used in production.
The refactored stack (active development):
| Layer | Technology |
|---|---|
| Frontend | Vite + React 19, TypeScript, urql + GraphQL Code Generator, AWS Amplify Auth |
| Backend | Node.js + TypeScript, AWS Lambda (container image), GraphQL Yoga + Pothos |
| Auth | AWS Cognito (TOTP MFA); Amplify Auth client-side |
| Database | AWS DynamoDB — per-user preference map (always-free tier) |
| External API | Unsplash API (weighted random travel photo selection) |
| Hosting | S3 + CloudFront (SPA + /graphql proxy); API Gateway HTTP API in front of Lambda |
| IaC | Terraform — three environments (dev / qa / prod) in one AWS account |
| CI/CD | GitHub Actions — lint, SAST, dep scan, IaC scan, SBOM, tests, deploy, DAST |
The archived v1 stack (Django + Create React App + Elastic Beanstalk) lives under legacy/ and is the reference for app behavior.
One CloudFront distribution fronts everything — the SPA from S3 at /*, and the
GraphQL API via API Gateway at /graphql. Same origin, no CORS, single TLS cert.
flowchart LR
Browser["🧑 Browser"]
subgraph CDN["AWS Edge"]
CF["CloudFront<br/>(/* + /graphql)"]
end
subgraph Frontend["Frontend (static)"]
S3["S3 Bucket<br/>React/Vite build"]
end
subgraph API["GraphQL API"]
APIGW["API Gateway<br/>HTTP API"]
Lambda["Lambda<br/>(container image)<br/>GraphQL Yoga + Pothos"]
end
subgraph Data["Stateful services"]
Dynamo[("DynamoDB<br/>per-user prefs")]
Cognito["Cognito User Pool<br/>TOTP MFA"]
SSM["SSM Parameter Store<br/>Unsplash key, etc."]
end
Unsplash["Unsplash API"]
Browser -- "HTTPS /*<br/>(SPA assets)" --> CF
Browser -- "HTTPS /graphql<br/>POST + JWT" --> CF
Browser <-- "Sign-up / sign-in / MFA<br/>(Amplify Auth)" --> Cognito
CF -- "/*" --> S3
CF -- "/graphql" --> APIGW
APIGW --> Lambda
Lambda -- "verify JWT (JWKS)" --> Cognito
Lambda --> Dynamo
Lambda --> SSM
Lambda --> Unsplash
Request flow for a typical mutation (e.g. submitFeedback):
- Amplify Auth in the browser supplies the Cognito ID token from secure cookies.
- urql sends
POST /graphqlwithAuthorization: Bearer <jwt>. - CloudFront forwards
/graphqlto the API Gateway HTTP API (default cache disabled,AllViewerExceptHostHeaderpolicy forwards the JWT). - API Gateway invokes the Lambda container with payload-format v2.
- The Lambda verifies the JWT against the Cognito pool's JWKS, runs the resolver
(updates
preferencesin DynamoDB, fetches Unsplash images fortravelImages, etc.), and returns the GraphQL response back through API Gateway → CloudFront → the browser.
Why API Gateway instead of a Lambda Function URL? The original plan called for
CloudFront → Lambda Function URL. New AWS accounts have a guardrail that blocks anonymous Function URL access regardless of resource policy, and the CloudFront OAC + SigV4 path proved brittle in practice. API Gateway HTTP APIs are free for the first 1M requests/month and route to the same Lambda with the same payload format — no signing required.
The refactor is underway — the v1 codebase is archived under legacy/ (still the
reference for app behavior) while the new TypeScript stack is built in frontend/,
backend/, and infra/. See the Roadmap.
CAPSTONE/
├── frontend/ # NEW — Vite + React + TypeScript SPA (built in Phase 2)
├── backend/ # NEW — Node.js + TypeScript GraphQL Lambda (built in Phase 1)
├── infra/ # NEW — Terraform infrastructure (built in Phase 3)
├── legacy/ # Archived v1 — reference only, removed after the refactor
│ ├── backend/ # v1 Django REST API
│ ├── frontend/ # v1 Create React App SPA
│ └── algorithm-testing/ # v1 algorithm prototypes
├── README.md
└── LICENSE.md
The instructions below are for the archived v1 app, now under
legacy/. Setup for the new TypeScript stack will be documented as it is built (Phases 1–3).
- Node.js 18+ and npm
- Python 3.10+
- A MySQL database (local or remote)
- A Google Places API key
cd legacy/backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserverCreate a .env file in backend/ (loaded via python-dotenv):
SECRET_KEY=your-django-secret-key
HOST_1=localhost
HOST_2=127.0.0.1
HOST_3=
HOST_4=
NAME=your_db_name # MySQL database name
USER=your_db_user # MySQL user (see Known Limitations)
PASSWORD=your_db_password
HOST=your_db_host
PORT=3306
KEY=your_google_places_api_keycd legacy/frontend
npm install
npm start # http://localhost:3000
npm run build # production build into frontend/build/The frontend reads REACT_APP_API_BASE_URL (see frontend/.env.production).
The production build is copied into backend/build/ so Django can serve it.
All endpoints are served by Django. Auth is session-based (cookie + CSRF).
| Method | Endpoint | Description |
|---|---|---|
| POST | /register/ |
Create a user account |
| POST | /login/ |
Authenticate, start a session |
| POST | /logout/ |
End the session |
| GET | /user-info/ |
Get name, username, preferences (auth required) |
| PATCH | /user-info/ |
Update name / email / password / preferences |
| DELETE | /delete-account/ |
Delete the current account |
| POST | /api/get-images/ |
Fetch preference-weighted Google Places photos |
| POST | /api/process-interactions/ |
Run feedback through the algorithm, return new preferences |
| GET | /, /* |
Serve the React SPA (index.html) |
v1 was deployed to AWS Elastic Beanstalk on the Python platform:
backend/.ebextensions/django.config— sets the WSGI path and static file routing.backend/.ebextensions/db-migrate.config— runsmanage.py migrateon deploy (leader instance only).collectstaticruns on deploy; the React build is served as Django static files.- The database is a MySQL instance on AWS RDS.
These are documented honestly to inform the refactor — see Roadmap.
- Broken auth flow. The frontend sends
Authorization: Bearer <localStorage token>, but Django uses session auth — no token is ever issued, so the header is inert.AuthContextkeepsisAuthenticatedonly in memory, so it resets on refresh. USERenv var collision.settings.pyreads the DB user fromos.environ.get('USER'). On Unix,USERis the OS-level current-user variable, which can silently override the intended value.- Inconsistent API base URL.
config.jshardcodes one URL; some pages useprocess.env.REACT_APP_API_BASE_URL; others call relative paths (/user-info/). - Bloated
requirements.txt. Includes unused MongoDB packages (djongo,mongoengine,pymongo),virtualenvwrapper-win, and other leftovers. - Repo hygiene. A 16 MB
debug.loganddb.sqlite3are committed;build/andstaticfiles/artifacts are tracked. - CORS relies on the SPA being served same-origin by Django; no
CORS_ALLOWED_ORIGINSis configured for a split deployment. - No automated tests beyond the Create React App default.
The project is being refactored with these goals:
- Rebuild the stack in TypeScript end-to-end (React frontend, Node backend).
- Re-deploy on the AWS always-free tier with AWS Lambda as the compute layer.
- Use Terraform for infrastructure as code.
- Adopt GraphQL (self-hosted in the Lambda) as the API.
- Move to a free-tier database (DynamoDB).
- Redesign preferences as a keyed per-country map (replacing the brittle positional array) — seeded neutral and learned from Travel feedback.
- Replace the broken auth with AWS Cognito — authenticator-app (TOTP) MFA.
- Add a CI/CD pipeline (GitHub Actions) with a full security-scanning suite (SAST, DAST, dependency/container/IaC scanning, SBOM) across DEV / QA / production.
- Clean up dependencies and split frontend/backend hosting.
| Phase | Scope | Status |
|---|---|---|
| 0 | Repo hygiene — archived v1, created new skeleton | ✅ Done |
| 1 | Backend — TypeScript GraphQL Lambda | 🔜 Next |
| 2 | Frontend — Vite + React + TypeScript SPA, Amplify Auth | ✅ Done |
| 3 | Terraform — all AWS infrastructure provisioned | ✅ Done |
| 4 | CI/CD — GitHub Actions pipelines + security scanning | ✅ Done |
| 5 | Deploy — DEV → QA → production release | 🚀 In progress — DEV live |
See LICENSE.md.
Collin Streitman