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:
2026-05-04 20:11:05 -07:00
parent 624b51654c
commit a0b16f7418
22 changed files with 5754 additions and 272 deletions
+42
View File
@@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://mealplanner:password@localhost:5432/mealplanner
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+55
View File
@@ -0,0 +1,55 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.models import Base
from app.config import settings
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("postgresql://", "postgresql+psycopg2://"))
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+3 -5
View File
@@ -1,7 +1,7 @@
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 sqlalchemy import text
from app.database import get_db
from app.config import settings
import logging
@@ -14,8 +14,6 @@ app = FastAPI(
version="0.1.0",
)
Base.metadata.create_all(bind=engine)
@app.get("/health")
def health_check(db: Session = Depends(get_db)):
@@ -25,7 +23,7 @@ def health_check(db: Session = Depends(get_db)):
@app.get("/health/db")
def health_check_db(db: Session = Depends(get_db)):
try:
db.execute("SELECT 1")
db.execute(text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception as e:
return {"status": "error", "database": "disconnected", "error": str(e)}
+164 -34
View File
@@ -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")