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
30 changes: 13 additions & 17 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
env:
AWS_REGION: ca-central-1
PYTHON_VERSION: "3.11"
NODE_VERSION: "22"
BUN_VERSION: "latest"
TF_VERSION: "1.6.0"

jobs:
Expand Down Expand Up @@ -41,13 +41,11 @@ jobs:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: oven-sh/setup-bun@v2
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run build
bun-version: ${{ env.BUN_VERSION }}
- run: bun install --frozen-lockfile
- run: bun run build

deploy-staging:
name: Deploy to Staging
Expand Down Expand Up @@ -93,19 +91,18 @@ jobs:
pip install -r requirements.txt -t package/
cp -r app package/
cd package && zip -r ../lambda.zip . -q
cd ..
aws lambda update-function-code --function-name ${{ steps.tf.outputs.lambda_name }} --zip-file fileb://lambda.zip --no-cli-pager
- uses: actions/setup-node@v4
- uses: oven-sh/setup-bun@v2
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
bun-version: ${{ env.BUN_VERSION }}
- name: Deploy frontend
env:
VITE_API_URL: ${{ steps.tf.outputs.api_url }}
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
cd frontend && npm ci && npm run build
cd frontend && bun install --frozen-lockfile && bun run build
aws s3 sync dist/ s3://${{ steps.tf.outputs.s3_bucket }}/ --delete --no-cli-pager
aws cloudfront create-invalidation --distribution-id ${{ steps.tf.outputs.cloudfront_id }} --paths "/*" --no-cli-pager

Expand Down Expand Up @@ -153,18 +150,17 @@ jobs:
pip install -r requirements.txt -t package/
cp -r app package/
cd package && zip -r ../lambda.zip . -q
cd ..
aws lambda update-function-code --function-name ${{ steps.tf.outputs.lambda_name }} --zip-file fileb://lambda.zip --no-cli-pager
- uses: actions/setup-node@v4
- uses: oven-sh/setup-bun@v2
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
bun-version: ${{ env.BUN_VERSION }}
- name: Deploy frontend
env:
VITE_API_URL: ${{ steps.tf.outputs.api_url }}
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
cd frontend && npm ci && npm run build
cd frontend && bun install --frozen-lockfile && bun run build
aws s3 sync dist/ s3://${{ steps.tf.outputs.s3_bucket }}/ --delete --no-cli-pager
aws cloudfront create-invalidation --distribution-id ${{ steps.tf.outputs.cloudfront_id }} --paths "/*" --no-cli-pager
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,30 @@
A clean, minimal food diary app. Log what you eat, see your timeline, track your streak.

## Tech Stack
- **Frontend**: React + Vite + TypeScript
- **Frontend**: React + Vite + TypeScript (Bun)
- **Backend**: FastAPI on AWS Lambda
- **Database**: Supabase PostgreSQL + Auth + RLS
- **Database**: Supabase PostgreSQL (schema: `heynom`) + Auth + RLS
- **Infra**: Terraform (CloudFront + S3 + API Gateway + Lambda)
- **CI/CD**: GitHub Actions
- **CI/CD**: GitHub Actions (Bun for frontend, Python for backend)

## Quick Start

```bash
cd frontend
npm install
npm run dev
bun install
bun dev
```

Open http://localhost:5173 — app runs in dev mode with local storage (no Supabase needed).
Open http://localhost:5173 — app runs in dev mode.

## Database Schema

Run this in Supabase SQL Editor:

```sql
create table food_entries (
create schema if not exists heynom;

create table heynom.food_entries (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) not null,
food_text text not null,
Expand All @@ -31,9 +35,13 @@ create table food_entries (
created_at timestamptz default now()
);

alter table food_entries enable row level security;
create policy "Users can CRUD own entries" on food_entries
alter table heynom.food_entries enable row level security;

create policy "Users can CRUD own entries" on heynom.food_entries
for all using (auth.uid() = user_id);

create index idx_food_entries_user_id on heynom.food_entries(user_id);
create index idx_food_entries_logged_at on heynom.food_entries(logged_at);
```

## Environment Variables
Expand All @@ -46,7 +54,8 @@ create policy "Users can CRUD own entries" on food_entries
### Backend
- `DATABASE_URL` — PostgreSQL connection string
- `SUPABASE_JWT_SECRET` — JWT secret for auth verification
- `CORS_ORIGINS` — Allowed origins
- `CORS_ORIGINS` — Allowed origins (comma-separated)

## Deploy
Push to `staging` or `main` branch — CI/CD handles the rest.

Pushes to `staging` deploy to staging. Pushes to `main` deploy to production. CI runs tests and Terraform automatically.
20 changes: 20 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""JWT auth — validates Supabase access tokens."""

import os
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError

SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "")
ALGORITHM = "HS256"

security = HTTPBearer()


def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
token = credentials.credentials
try:
payload = jwt.decode(token, SUPABASE_JWT_SECRET, algorithms=[ALGORITHM], options={"verify_aud": False})
return payload
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
40 changes: 40 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import os
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker

DATABASE_URL = os.environ.get("DATABASE_URL", "")

_engine = None
_SessionLocal = None


def _get_engine():
global _engine
if _engine is None:
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL not set")
_engine = create_engine(DATABASE_URL, pool_pre_ping=True)

@event.listens_for(_engine, "connect")
def set_search_path(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("SET search_path TO heynom, public")
cursor.close()

return _engine


def _get_session_local():
global _SessionLocal
if _SessionLocal is None:
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_get_engine())
return _SessionLocal


def get_db():
SessionLocal = _get_session_local()
db = SessionLocal()
try:
yield db
finally:
db.close()
3 changes: 2 additions & 1 deletion backend/app/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from sqlalchemy import Column, String, Text, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import declarative_base
import uuid

Base = declarative_base()

class FoodEntry(Base):
__tablename__ = "food_entries"
__table_args__ = {"schema": "heynom"}

id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False)
Expand Down
65 changes: 55 additions & 10 deletions backend/app/routers/food.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,66 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
from datetime import datetime, timezone

from ..auth import get_current_user
from ..database import get_db
from ..models import FoodEntry
from ..schemas import FoodEntryCreate, FoodEntryResponse

router = APIRouter(prefix="/food", tags=["food"])


@router.get("/", response_model=List[FoodEntryResponse])
async def list_food_entries(limit: int = 100):
# TODO: implement with database
return []
async def list_food_entries(
limit: int = 200,
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_id = user.get("sub")
entries = (
db.query(FoodEntry)
.filter(FoodEntry.user_id == user_id)
.order_by(FoodEntry.logged_at.desc())
.limit(limit)
.all()
)
return entries


@router.post("/", response_model=FoodEntryResponse, status_code=201)
async def create_food_entry(entry: FoodEntryCreate):
# TODO: implement with database
raise HTTPException(status_code=501, detail="Not implemented")
async def create_food_entry(
entry: FoodEntryCreate,
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_id = user.get("sub")
db_entry = FoodEntry(
user_id=user_id,
food_text=entry.food_text,
meal_type=entry.meal_type,
logged_at=entry.logged_at or datetime.now(timezone.utc),
)
db.add(db_entry)
db.commit()
db.refresh(db_entry)
return db_entry


@router.delete("/{entry_id}", status_code=204)
async def delete_food_entry(entry_id: UUID):
# TODO: implement with database
raise HTTPException(status_code=501, detail="Not implemented")
async def delete_food_entry(
entry_id: UUID,
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_id = user.get("sub")
entry = (
db.query(FoodEntry)
.filter(FoodEntry.id == entry_id, FoodEntry.user_id == user_id)
.first()
)
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
db.delete(entry)
db.commit()
Loading