feat: add Phase 1 infrastructure skeleton

Backend (FastAPI):
- docker-compose with all 4 services
- FastAPI app with health endpoints
- SQLAlchemy models for all tables
- Placeholder API endpoints for all routes
- Config and database modules
- requirements.txt with all dependencies

Frontend (React):
- package.json with React, Tailwind, React Query, React Router
- Vite config with API proxy
- Tailwind and TypeScript configs
- Basic App with routing skeleton
- Placeholder pages (Dashboard, MealDetail, Pantry)

Infrastructure:
- nginx config for reverse proxy
- Dockerfile for backend and frontend
This commit is contained in:
2026-05-04 19:29:35 -07:00
parent 0c5b0aa5ed
commit 1328ec359d
26 changed files with 761 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python -c "from playwright.sync_api import sync_playwright; \
p = sync_playwright().start(); \
p.chromium.download(); \
p.stop()" || echo "Playwright browser download skipped"
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
router = APIRouter()
@router.post("/scrape")
def trigger_scrape(db: Session = Depends(get_db)):
return {"message": "Scrape trigger - not yet implemented"}
@router.get("/logs")
def get_logs(db: Session = Depends(get_db)):
return {"message": "Logs endpoint - not yet implemented"}
@router.post("/test-email")
def test_email(db: Session = Depends(get_db)):
return {"message": "Test email - not yet implemented"}
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
router = APIRouter()
@router.get("/planned")
def get_planned_meals(db: Session = Depends(get_db)):
return {"message": "Planned meals endpoint - not yet implemented"}
@router.post("/{meal_id}/approve")
def approve_meal(meal_id: str, db: Session = Depends(get_db)):
return {"message": f"Approve meal {meal_id} - not yet implemented"}
@router.post("/{meal_id}/deny")
def deny_meal(meal_id: str, db: Session = Depends(get_db)):
return {"message": f"Deny meal {meal_id} - not yet implemented"}
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
router = APIRouter()
@router.get("/")
def get_pantry(db: Session = Depends(get_db)):
return {"message": "Pantry endpoint - not yet implemented"}
@router.post("/")
def add_pantry_item(db: Session = Depends(get_db)):
return {"message": "Add pantry item - not yet implemented"}
@router.delete("/{item_id}")
def remove_pantry_item(item_id: str, db: Session = Depends(get_db)):
return {"message": f"Remove pantry item {item_id} - not yet implemented"}
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
router = APIRouter()
@router.get("/")
def get_profile(db: Session = Depends(get_db)):
return {"message": "Profile endpoint - not yet implemented"}
@router.put("/")
def update_profile(db: Session = Depends(get_db)):
return {"message": "Update profile endpoint - not yet implemented"}
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
router = APIRouter()
@router.get("/")
def get_recipes(db: Session = Depends(get_db)):
return {"message": "Recipes endpoint - not yet implemented"}
@router.get("/{recipe_id}")
def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
return {"message": f"Recipe {recipe_id} - not yet implemented"}
@router.post("/")
def create_recipe(db: Session = Depends(get_db)):
return {"message": "Create recipe endpoint - not yet implemented"}
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
router = APIRouter()
@router.get("/")
def get_shopping_list(db: Session = Depends(get_db)):
return {"message": "Shopping list endpoint - not yet implemented"}
@router.get("/print")
def get_printable_shopping_list(db: Session = Depends(get_db)):
return {"message": "Printable shopping list - not yet implemented"}
+23
View File
@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
DATABASE_URL: str
SENDGRID_API_KEY: Optional[str] = None
LUCKY_CA_URL: str = "https://www.luckyncal.com"
AI_IMAGE_ENABLED: bool = False
AI_IMAGE_PROVIDER: Optional[str] = None
AI_IMAGE_API_KEY: Optional[str] = None
LOG_LEVEL: str = "INFO"
SECRET_KEY: str = "dev-secret-key"
FAMILY_EMAIL_1: Optional[str] = None
FAMILY_EMAIL_2: Optional[str] = None
RECIPES_EMAIL: Optional[str] = None
class Config:
env_file = ".env"
settings = Settings()
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+41
View File
@@ -0,0 +1,41 @@
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.database import get_db, engine, Base
from app.models import family_profile, ingredient, recipe, meal_plan, home_pantry, feedback, grocery_item, scrape_log, email_log
from app.config import settings
import logging
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger(__name__)
app = FastAPI(
title="MealPlanner",
description="Self-hosted meal planning system",
version="0.1.0",
)
Base.metadata.create_all(bind=engine)
@app.get("/health")
def health_check(db: Session = Depends(get_db)):
return {"status": "ok"}
@app.get("/health/db")
def health_check_db(db: Session = Depends(get_db)):
try:
db.execute("SELECT 1")
return {"status": "ok", "database": "connected"}
except Exception as e:
return {"status": "error", "database": "disconnected", "error": str(e)}
from app.api import profile, recipes, meals, shopping_list, pantry, admin
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(recipes.router, prefix="/api/recipes", tags=["recipes"])
app.include_router(meals.router, prefix="/api/meals", tags=["meals"])
app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"])
app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
+248
View File
@@ -0,0 +1,248 @@
from sqlalchemy import (
Column, String, Integer, Numeric, Text, Boolean, Date, DateTime,
ForeignKey, CheckConstraint, UniqueConstraint, ARRAY
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import uuid
class FamilyProfile(Base):
__tablename__ = "family_profile"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
household_size = Column(Integer, nullable=False)
adult_count = Column(Integer, nullable=False)
child_count = Column(Integer, nullable=False)
dietary_notes = Column(Text)
budget_per_meal = Column(Numeric(10, 2), default=50.00)
calorie_target = Column(Integer)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
recipes = relationship("Recipe", back_populates="family_profile")
meal_plans = relationship("MealPlan", back_populates="family_profile")
pantry_items = relationship("HomePantry", back_populates="family_profile")
feedbacks = relationship("Feedback", back_populates="family_profile")
never_suggests = relationship("NeverSuggest", back_populates="family_profile")
class Ingredient(Base):
__tablename__ = "ingredient"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False, unique=True)
plural_name = Column(String(200))
aisle = Column(String(100))
typical_price = Column(Numeric(10, 2))
unit = Column(String(50))
season_months = Column(ARRAY(Integer))
created_at = Column(DateTime(timezone=True), server_default=func.now())
recipes = relationship("RecipeIngredient", back_populates="ingredient")
pantry_items = relationship("HomePantry", back_populates="ingredient")
never_suggests = relationship("NeverSuggest", back_populates="ingredient")
class Recipe(Base):
__tablename__ = "recipe"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
name = Column(String(300), nullable=False)
description = Column(Text)
image_url = Column(Text)
image_source = Column(String(50))
prep_time_minutes = Column(Integer)
cook_time_minutes = Column(Integer)
servings = Column(Integer, nullable=False)
servings_scaled = Column(Integer)
cuisine_tags = Column(ARRAY(String(50)))
dietary_tags = Column(ARRAY(String(50)))
protein_type = Column(String(50))
spice_level = Column(Integer)
ingredients = Column(JSONB, nullable=False)
instructions = Column(ARRAY(Text), nullable=False)
source_url = Column(Text)
scraped_at = Column(DateTime(timezone=True))
is_manually_added = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
family_profile = relationship("FamilyProfile", back_populates="recipes")
meal_plan_items = relationship("MealPlanItem", back_populates="recipe")
class MealPlan(Base):
__tablename__ = "meal_plan"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
week_start_date = Column(Date, nullable=False)
status = Column(String(20), nullable=False, default="draft")
approval_deadline = Column(DateTime(timezone=True))
total_estimated_cost = Column(Numeric(10, 2))
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("family_profile_id", "week_start_date"),
)
family_profile = relationship("FamilyProfile", back_populates="meal_plans")
items = relationship("MealPlanItem", back_populates="meal_plan", cascade="all, delete-orphan")
class MealPlanItem(Base):
__tablename__ = "meal_plan_item"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id", ondelete="CASCADE"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
day_of_week = Column(Integer, nullable=False)
meal_type = Column(String(20), nullable=False)
approval_status = Column(String(20), default="pending")
approval_token = Column(UUID(as_uuid=True), unique=True, default=uuid.uuid4)
approval_token_expires = Column(DateTime(timezone=True))
denial_reason = Column(String(50))
denial_details = Column(Text)
estimated_cost = Column(Numeric(10, 2))
used_pantry_items = Column(ARRAY(UUID(as_uuid=True)))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_id", "day_of_week", "meal_type"),
CheckConstraint("day_of_week BETWEEN 0 AND 6"),
)
meal_plan = relationship("MealPlan", back_populates="items")
recipe = relationship("Recipe", back_populates="meal_plan_items")
feedback = relationship("Feedback", back_populates="meal_plan_item", uselist=False)
class HomePantry(Base):
__tablename__ = "home_pantry"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
quantity = Column(Numeric(10, 2))
unit = Column(String(50))
expires_at = Column(Date)
added_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("family_profile_id", "ingredient_id"),
)
family_profile = relationship("FamilyProfile", back_populates="pantry_items")
ingredient = relationship("Ingredient", back_populates="pantry_items")
class Feedback(Base):
__tablename__ = "feedback"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
rating = Column(Integer)
never_suggest = Column(Boolean, default=False)
denial_reason = Column(String(50))
feedback_text = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_item_id"),
CheckConstraint("rating BETWEEN 1 AND 5"),
)
family_profile = relationship("FamilyProfile", back_populates="feedbacks")
meal_plan_item = relationship("MealPlanItem", back_populates="feedback")
class NeverSuggest(Base):
__tablename__ = "never_suggest"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
reason = Column(String(50))
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
family_profile = relationship("FamilyProfile", back_populates="never_suggests")
ingredient = relationship("Ingredient", back_populates="never_suggests")
class GroceryItem(Base):
__tablename__ = "grocery_item"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(300), nullable=False)
brand = Column(String(200))
current_price = Column(Numeric(10, 2))
regular_price = Column(Numeric(10, 2))
unit = Column(String(50))
aisle = Column(String(100))
image_url = Column(Text)
product_url = Column(Text)
is_on_sale = Column(Boolean, default=False)
sale_start_date = Column(Date)
sale_end_date = Column(Date)
in_season = Column(Boolean, default=False)
scraped_at = Column(DateTime(timezone=True), server_default=func.now())
scraped_url = Column(Text)
class ScrapeLog(Base):
__tablename__ = "scrape_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source = Column(String(50), nullable=False)
scrape_type = Column(String(50), nullable=False)
status = Column(String(20), nullable=False)
items_scraped = Column(Integer, default=0)
error_message = Column(Text)
started_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True))
duration_seconds = Column(Integer)
class EmailLog(Base):
__tablename__ = "email_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
recipient_email = Column(String(300), nullable=False)
recipient_name = Column(String(200))
template = Column(String(100), nullable=False)
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id"))
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id"))
sendgrid_message_id = Column(String(100))
status = Column(String(20), nullable=False)
error_message = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
delivered_at = Column(DateTime(timezone=True))
meal_plan = relationship("MealPlan")
meal_plan_item = relationship("MealPlanItem")
class RecipeIngredient(Base):
__tablename__ = "recipe_ingredient"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
quantity = Column(Numeric(10, 2))
unit = Column(String(50))
is_optional = Column(Boolean, default=False)
recipe = relationship("Recipe", back_populates="recipe_ingredients")
ingredient = relationship("Ingredient", back_populates="recipes")
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
alembic==1.13.1
psycopg2-binary==2.9.9
pydantic==2.5.3
pydantic-settings==2.1.0
sendgrid==6.12.0
playwright==1.41.0
beautifulsoup4==4.12.3
lxml==5.1.0
apscheduler==3.10.4
python-dotenv==1.0.0
httpx==0.26.0
pytest==7.4.4
pytest-asyncio==0.23.3