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
+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")