Public Access
fix: address adversarial review blockers
All §1 consensus blockers and §2 high-risk gaps resolved: Schema fixes: - Remove RecipeIngredient join table, use JSONB for ingredients - Add family_member table for per-voter approval tracking - Add all ENUMs for status fields (no loose VARCHAR) - Add CHECK constraints (household_size, rating 1-5, day_of_week) - Add name_lower for case-insensitive ingredient matching - Add grocery_item → ingredient FK - Fix day_of_week to ISO-8601 (1=Monday, 7=Sunday) - Remove calorie_target (nutrition is non-goal) Approval flow redesign: - Email link → confirmation page (GET), not auto-approve - Actual vote is POST from confirmation page - Per-voter tokens (single-use, 72h TTL) - Record which member voted Auth model: - VPN-only for admin endpoints - Session-based for family web UI Docker hardening: - Remove direct port exposure for backend/frontend - nginx is sole entrypoint - Add docker-compose.dev.yml for local dev Skeleton fixes: - Add missing Pantry.tsx page - Add missing index.html (Vite entrypoint) - Add package-lock.json - Fix SQLAlchemy 2 text() for raw SQL - Remove create_all from startup (use migrations) - Configure Alembic properly Docs updates: - Update Lucky URL to luckysupermarkets.com - Add WCAG 2.1 AA accessibility target - Update family profile with correct mushroom preferences - Add external dependencies list to SPEC Verification: - docker compose config: PASS - docker compose build backend: PASS - docker compose build frontend: PASS - backend import: PASS - alembic context: PASS
This commit is contained in:
+164
-34
@@ -1,12 +1,82 @@
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, Numeric, Text, Boolean, Date, DateTime,
|
||||
ForeignKey, CheckConstraint, UniqueConstraint, ARRAY
|
||||
ForeignKey, CheckConstraint, UniqueConstraint, Enum as SQLEnum
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
import enum
|
||||
|
||||
|
||||
class DayOfWeek(enum.Enum):
|
||||
MONDAY = 1
|
||||
TUESDAY = 2
|
||||
WEDNESDAY = 3
|
||||
THURSDAY = 4
|
||||
FRIDAY = 5
|
||||
SATURDAY = 6
|
||||
SUNDAY = 7
|
||||
|
||||
|
||||
class MealType(enum.Enum):
|
||||
BREAKFAST = "breakfast"
|
||||
LUNCH = "lunch"
|
||||
DINNER = "dinner"
|
||||
|
||||
|
||||
class MealPlanStatus(enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
APPROVED = "approved"
|
||||
LOCKED = "locked"
|
||||
|
||||
|
||||
class MealPlanItemStatus(enum.Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
DENIED = "denied"
|
||||
SWAPPED = "swapped"
|
||||
|
||||
|
||||
class ApprovalTokenStatus(enum.Enum):
|
||||
ACTIVE = "active"
|
||||
USED = "used"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class FamilyMemberRole(enum.Enum):
|
||||
ADULT = "adult"
|
||||
CHILD = "child"
|
||||
|
||||
|
||||
class DenialReason(enum.Enum):
|
||||
TOO_EXPENSIVE = "too_expensive"
|
||||
BORING = "boring"
|
||||
DISLIKED_INGREDIENT = "disliked_ingredient"
|
||||
CULTURAL = "cultural"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class NeverSuggestReason(enum.Enum):
|
||||
ALLERGY = "allergy"
|
||||
DISLIKE = "dislike"
|
||||
TRIED_TOO_MUCH = "tried_too_much"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class ScrapeStatus(enum.Enum):
|
||||
STARTED = "started"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class EmailStatus(enum.Enum):
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
FAILED = "failed"
|
||||
BOUNCED = "bounced"
|
||||
|
||||
|
||||
class FamilyProfile(Base):
|
||||
@@ -23,18 +93,47 @@ class FamilyProfile(Base):
|
||||
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__ = (
|
||||
CheckConstraint("adult_count + child_count = household_size"),
|
||||
CheckConstraint("household_size > 0"),
|
||||
CheckConstraint("adult_count > 0"),
|
||||
)
|
||||
|
||||
members = relationship("FamilyMember", back_populates="family_profile", cascade="all, delete-orphan")
|
||||
recipes = relationship("Recipe", back_populates="family_profile")
|
||||
meal_plans = relationship("MealPlan", back_populates="family_profile")
|
||||
pantry_items = relationship("HomePantry", back_populates="family_profile")
|
||||
pantry_items = relationship("HomePantry", back_populates="family_profile", cascade="all, delete-orphan")
|
||||
feedbacks = relationship("Feedback", back_populates="family_profile")
|
||||
never_suggests = relationship("NeverSuggest", back_populates="family_profile")
|
||||
never_suggests = relationship("NeverSuggest", back_populates="family_profile", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class FamilyMember(Base):
|
||||
__tablename__ = "family_member"
|
||||
|
||||
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"))
|
||||
name = Column(String(100), nullable=False)
|
||||
email = Column(String(300))
|
||||
role = Column(SQLEnum(FamilyMemberRole, name="family_member_role_enum", create_type=False), nullable=False)
|
||||
likes_mushrooms = 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())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("family_profile_id", "email"),
|
||||
)
|
||||
|
||||
family_profile = relationship("FamilyProfile", back_populates="members")
|
||||
votes = relationship("MealPlanVote", back_populates="family_member", cascade="all, delete-orphan")
|
||||
feedbacks = relationship("Feedback", back_populates="family_member")
|
||||
|
||||
|
||||
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)
|
||||
name = Column(String(200), nullable=False)
|
||||
name_lower = Column(String(200), nullable=False, unique=True)
|
||||
plural_name = Column(String(200))
|
||||
aisle = Column(String(100))
|
||||
typical_price = Column(Numeric(10, 2))
|
||||
@@ -42,9 +141,9 @@ class Ingredient(Base):
|
||||
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")
|
||||
grocery_item_links = relationship("GroceryItem", back_populates="ingredient")
|
||||
|
||||
|
||||
class Recipe(Base):
|
||||
@@ -75,6 +174,10 @@ class Recipe(Base):
|
||||
family_profile = relationship("FamilyProfile", back_populates="recipes")
|
||||
meal_plan_items = relationship("MealPlanItem", back_populates="recipe")
|
||||
|
||||
@property
|
||||
def total_time_minutes(self):
|
||||
return (self.prep_time_minutes or 0) + (self.cook_time_minutes or 0)
|
||||
|
||||
|
||||
class MealPlan(Base):
|
||||
__tablename__ = "meal_plan"
|
||||
@@ -82,7 +185,7 @@ class MealPlan(Base):
|
||||
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")
|
||||
status = Column(SQLEnum(MealPlanStatus, name="meal_plan_status_enum", create_type=False), nullable=False, default=MealPlanStatus.DRAFT)
|
||||
approval_deadline = Column(DateTime(timezone=True))
|
||||
total_estimated_cost = Column(Numeric(10, 2))
|
||||
notes = Column(Text)
|
||||
@@ -95,6 +198,7 @@ class MealPlan(Base):
|
||||
|
||||
family_profile = relationship("FamilyProfile", back_populates="meal_plans")
|
||||
items = relationship("MealPlanItem", back_populates="meal_plan", cascade="all, delete-orphan")
|
||||
votes = relationship("MealPlanVote", back_populates="meal_plan", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MealPlanItem(Base):
|
||||
@@ -104,11 +208,9 @@ class MealPlanItem(Base):
|
||||
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))
|
||||
meal_type = Column(SQLEnum(MealType, name="meal_type_enum", create_type=False), nullable=False)
|
||||
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False), default=MealPlanItemStatus.PENDING)
|
||||
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False))
|
||||
denial_details = Column(Text)
|
||||
estimated_cost = Column(Numeric(10, 2))
|
||||
used_pantry_items = Column(ARRAY(UUID(as_uuid=True)))
|
||||
@@ -117,12 +219,50 @@ class MealPlanItem(Base):
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("meal_plan_id", "day_of_week", "meal_type"),
|
||||
CheckConstraint("day_of_week BETWEEN 0 AND 6"),
|
||||
CheckConstraint("day_of_week BETWEEN 1 AND 7"),
|
||||
)
|
||||
|
||||
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)
|
||||
votes = relationship("MealPlanVote", back_populates="meal_plan_item", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MealPlanVote(Base):
|
||||
__tablename__ = "meal_plan_vote"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
|
||||
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
|
||||
vote = Column(Boolean, nullable=False)
|
||||
voted_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("meal_plan_item_id", "family_member_id"),
|
||||
)
|
||||
|
||||
meal_plan_item = relationship("MealPlanItem", back_populates="votes")
|
||||
family_member = relationship("FamilyMember", back_populates="votes")
|
||||
|
||||
|
||||
class ApprovalToken(Base):
|
||||
__tablename__ = "approval_token"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
|
||||
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
|
||||
token = Column(String(64), nullable=False, unique=True)
|
||||
status = Column(SQLEnum(ApprovalTokenStatus, name="approval_token_status_enum", create_type=False), default=ApprovalTokenStatus.ACTIVE)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
used_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("meal_plan_item_id", "family_member_id"),
|
||||
)
|
||||
|
||||
meal_plan_item = relationship("MealPlanItem")
|
||||
family_member = relationship("FamilyMember")
|
||||
|
||||
|
||||
class HomePantry(Base):
|
||||
@@ -150,19 +290,20 @@ class Feedback(Base):
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
|
||||
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="SET NULL"))
|
||||
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))
|
||||
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False))
|
||||
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")
|
||||
family_member = relationship("FamilyMember", back_populates="feedbacks")
|
||||
meal_plan_item = relationship("MealPlanItem", back_populates="feedback")
|
||||
|
||||
|
||||
@@ -171,9 +312,9 @@ class NeverSuggest(Base):
|
||||
|
||||
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))
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"))
|
||||
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
|
||||
reason = Column(SQLEnum(NeverSuggestReason, name="never_suggest_reason_enum", create_type=False))
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -185,6 +326,7 @@ class GroceryItem(Base):
|
||||
__tablename__ = "grocery_item"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="SET NULL"))
|
||||
name = Column(String(300), nullable=False)
|
||||
brand = Column(String(200))
|
||||
current_price = Column(Numeric(10, 2))
|
||||
@@ -200,6 +342,8 @@ class GroceryItem(Base):
|
||||
scraped_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
scraped_url = Column(Text)
|
||||
|
||||
ingredient = relationship("Ingredient", back_populates="grocery_item_links")
|
||||
|
||||
|
||||
class ScrapeLog(Base):
|
||||
__tablename__ = "scrape_log"
|
||||
@@ -207,7 +351,7 @@ class ScrapeLog(Base):
|
||||
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)
|
||||
status = Column(SQLEnum(ScrapeStatus, name="scrape_status_enum", create_type=False), nullable=False)
|
||||
items_scraped = Column(Integer, default=0)
|
||||
error_message = Column(Text)
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -225,24 +369,10 @@ class EmailLog(Base):
|
||||
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)
|
||||
status = Column(SQLEnum(EmailStatus, name="email_status_enum", create_type=False), 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")
|
||||
|
||||
Reference in New Issue
Block a user