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
27 changes: 27 additions & 0 deletions .claude/commands/pr-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Write a PR description for the current branch and save it to `docs/pr-description.md`.

Use `git log main..HEAD` and `git diff main..HEAD` to understand what changed.

Rules:
- Do not list files changed — that's visible in the diff
- Focus on WHY, not WHAT
- Explain decisions that aren't obvious from the code
- Note anything a reviewer should pay attention to
- Keep it concise — one bullet per decision, one sharp reason per bullet
- End with a "What's next" line if there's a clear next step
- Use plain GitHub-flavoured markdown, no emoji
- Tone: professional, humble, clear

Sharpness rules:
- Don't restate the title in the opening summary — if the sentence adds nothing, cut it
- "What is in diff, is in diff" — never describe which files changed or say "this is reflected in X, Y, Z"
- No anecdote or backstory ("this bit us", "we discovered") — state the technical constraint directly
- Each bullet: decision + the one sharp reason it was made, nothing else
- Don't over-explain consequences that follow obviously from the constraint
- Cut filler: "zero complexity", "available to anyone", "particularly useful for"

Structure:
## <short title>
<1 sentence — only if it adds something the title doesn't>
### Decisions worth noting
### What's next
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,5 @@ frontend/.vite/
Thumbs.db

# Project specific
docs/context.md
docs/context.md
docs/pr-description.md
23 changes: 20 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ uv run pytest tests/path/to/test_file.py::test_name
Household → User (many per household)
→ Location (e.g. Fridge, Cellar, Pantry — seeded on creation)
→ Category (e.g. Food, Cleaning — seeded on creation)
→ Item (what a product *is*; has barcode, location, category)
→ Batch (one per purchase; holds quantity + expiry date)
→ Item (what a product *is*; has barcode, brand, image_url, category)
→ Batch (one per unique location + expiry combo; holds quantity, location, expiry)
```

- **Item** = the product definition. **Batch** = a specific purchase of that item with its own quantity and expiry.
- **Item** = the product definition (name, barcode, brand, image_url from Open Food Facts, category, unit). No location — the same product can live in multiple places.
- **Batch** = one group of units sharing the same location and expiry date. A purchase split across two locations creates two batches.
- Expiry granularity is month + year only; defaults to +12 months from today.
- `ShoppingListItem` model exists in v1 but has no UI — that's a v2 feature.

Expand All @@ -80,6 +81,22 @@ FastAPI Users with JWT tokens. Registration creates both a new User and a new Ho
- Ruff enforces strict rules including mandatory type annotations (`ANN`) and no `print` statements (`T20`). Tests are exempt from annotations and security rules — see `pyproject.toml` for the full per-file ignore list.
- `asyncio_mode = "auto"` is set in pytest — no need to mark async tests explicitly.

### Datetime columns

All `datetime` fields must use `sa_column=sa.Column(sa.DateTime(timezone=True))` — asyncpg rejects timezone-aware datetimes in `TIMESTAMP WITHOUT TIME ZONE` columns. Always use `datetime.now(UTC)` (never `datetime.utcnow()`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent additions! 🎉

These new sections (datetime columns, schema changes, fastapi-users imports) are super helpful for future work. The datetime guidance especially — the asyncpg timezone issue is a common gotcha.

One small suggestion: the "No Alembic yet" section might want to note that this is intentional for now and will be added before production. Just to make it clear this is a conscious decision, not an oversight. E.g.:

### Schema changes

No Alembic yet — schema changes require dropping tables in Neon console and restarting the server. **This is intentional for rapid prototyping.** Add Alembic before any production data exists.

Makes it clear to a reviewer (or future you) that this is a deliberate trade-off for v1.

### Schema changes

No Alembic yet. Schema changes require dropping affected tables in the Neon console and restarting the server to recreate them. Add Alembic before any production data exists.

### fastapi-users imports

Import `SQLAlchemyUserDatabase` directly from `fastapi_users_db_sqlalchemy`, not `fastapi_users.db`. The re-export in `fastapi_users.db` uses a `try/except` block that Pylance cannot statically trace, causing false unknown-symbol errors.

## Developer environment

Host machine: Apple M4 MacBook (ARM64, no Rosetta). Package manager: Homebrew. Shell: zsh. `gh` CLI is installed on the host — not inside the Claude Code container.

## CI

A GitHub Actions workflow (`.github/workflows/claude-review.yml`) runs Claude Code review on every non-draft PR to `main`. It focuses on data isolation bugs, missing input validation, security issues, and learning-oriented feedback.
4 changes: 4 additions & 0 deletions backend/app/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from app.auth.backend import auth_backend
from app.config import get_settings
from app.database import get_session
from app.models.category import Category
from app.models.household import Household
from app.models.location import Location
from app.models.user import User


Expand Down Expand Up @@ -48,6 +50,8 @@ async def on_after_register(
self.session.add(household)
await self.session.flush() # writes household row and populates household.id
user.household_id = household.id # session tracks this change automatically
self.session.add(Location(household_id=household.id, name="Pantry"))
self.session.add(Category(household_id=household.id, name="Food"))
await self.session.commit()
except Exception:
await self.user_db.delete(user)
Expand Down
6 changes: 5 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from app.models.batch import Batch
from app.models.category import Category
from app.models.household import Household
from app.models.item import Item
from app.models.location import Location
from app.models.user import User

__all__ = ["Household", "User"]
__all__ = ["Batch", "Category", "Household", "Item", "Location", "User"]
25 changes: 25 additions & 0 deletions backend/app/models/batch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from datetime import UTC, date, datetime, timedelta

import sqlalchemy as sa
from sqlmodel import Field, SQLModel


class Batch(SQLModel, table=True):
__tablename__ = "batches" # type: ignore[assignment]

id: int | None = Field(default=None, primary_key=True)
# Denormalized from Item for query-level household isolation — every batch query can filter
# directly on household_id without joining through items.
household_id: int = Field(foreign_key="households.id", index=True)
item_id: int = Field(foreign_key="items.id", index=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Missing household_id isolation!

This is the most important security issue in the PR. Batch needs to enforce household isolation, but it only has item_id and location_id as foreign keys.

The problem:

  • A malicious user could craft a request with a valid item_id from another household and create/modify batches for items they don't own.
  • Even if your API endpoints check permissions, having the FK structure correct is defense-in-depth.

Two options:

Option A: Denormalize (recommended for this project):
Add household_id directly to Batch:

household_id: int = Field(foreign_key="households.id", index=True)

Then every query can filter WHERE household_id = current_user.household_id. Yes, it's redundant with Item.household_id, but it makes queries simpler and safer.

Option B: Use composite foreign keys:
Define a FK constraint like (item_id, household_id) → (items.id, items.household_id). This is more normalized but adds complexity.

For a learning/portfolio project, Option A is cleaner and easier to explain in an interview. The denormalization trade-off (a bit of redundancy for query simplicity and security) is a real-world pattern used by companies like Stripe and GitHub.

Same issue exists for Category and Location models below.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied to batch, category and location are already correct.

quantity: int = Field(sa_column=sa.Column(sa.Integer, sa.CheckConstraint("quantity > 0"), nullable=False))
# Defaults to approximately +12 months from today.
# Month + year precision only — the day component is not shown in the UI.
expiry_date: date = Field(
default_factory=lambda: date.today() + timedelta(days=365)
)
location_id: int = Field(foreign_key="locations.id")
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False),
)
11 changes: 11 additions & 0 deletions backend/app/models/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import sqlalchemy as sa
from sqlmodel import Field, SQLModel


class Category(SQLModel, table=True):
__tablename__ = "categories" # type: ignore[assignment]
__table_args__ = (sa.UniqueConstraint("household_id", "name", name="uq_category_household_name"),)

id: int | None = Field(default=None, primary_key=True)
household_id: int = Field(foreign_key="households.id", index=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: household_id is present here! ✅

But missing index: You have index=True on the FK, which is great for joins. However, you'll also be querying WHERE household_id = X frequently (to list all categories for a household).

Already done: Actually, looking at this again, index=True is set on the FK! That's exactly right. This will create an index on household_id, which speeds up those queries.

Nice work! This is the correct pattern. Now just need to apply the same household_id field to Batch (see earlier comment).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as Location: Should category names be unique per household?

Given that categories are seeded on household creation (per CLAUDE.md), you probably don't want duplicates. Users shouldn't be able to manually create a second "Food" category.

Consider adding the same UniqueConstraint('household_id', 'name') as suggested for Location.

Alternatively, if categories are meant to be managed only by seeding (not user-editable), you could enforce that at the API level by not exposing create/update endpoints. But the DB constraint is safer defense-in-depth.

name: str
35 changes: 35 additions & 0 deletions backend/app/models/item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from datetime import UTC, datetime

import sqlalchemy as sa
from sqlmodel import Field, SQLModel


class Item(SQLModel, table=True):
__tablename__ = "items" # type: ignore[assignment]
# NULL barcodes are exempt — PostgreSQL does not consider NULLs equal in unique constraints,
# so multiple items without a barcode in the same household are allowed.
__table_args__ = (sa.UniqueConstraint("household_id", "barcode", name="uq_item_household_barcode"),)

id: int | None = Field(default=None, primary_key=True)
household_id: int = Field(foreign_key="households.id", index=True)
name: str

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String field length limits:

Fields like name, brand, image_url, and notes are defined as str with no max length. In PostgreSQL, this creates a TEXT column (unlimited), which is fine for storage, but...

Potential issues:

  1. No validation: A malicious or buggy client could send a 10MB string for name, which would be accepted.
  2. UI concerns: If name is 1000 characters, how does it display on mobile?

Suggestion: Add reasonable max lengths via Pydantic:

from sqlmodel import Field

name: str = Field(max_length=200)
brand: str | None = Field(default=None, max_length=100)
image_url: str | None = Field(default=None, max_length=500)
notes: str | None = Field(default=None, max_length=1000)

These get enforced by Pydantic validation and create VARCHAR(N) columns in Postgres (though TEXT is also fine for Postgres — it's more about the validation).

For image_url specifically, 500 chars should be plenty. For notes, 1000 is reasonable for a mobile form. Adjust to taste!

Not a critical bug, but good defensive programming.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validation to be implemented in the request schemas, db schemas and table model is not the correct place

barcode: str | None = Field(default=None, index=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Should barcode be unique per household?

Right now barcode has index=True but no uniqueness constraint. This means:

  • Multiple items in the same household could have the same barcode
  • Different households can definitely have the same barcode (which is correct!)

Real-world scenario:
User scans a barcode, app checks Open Food Facts, creates Item A. Later they scan the same barcode again — should it:

  1. Find and reuse Item A (adding a new Batch), or
  2. Allow creating Item B with the same barcode?

Suggestion: If the answer is (1), add a composite unique constraint:

from sqlalchemy import UniqueConstraint

class Item(SQLModel, table=True):
    # ... fields ...
    
    __table_args__ = (
        UniqueConstraint('household_id', 'barcode', name='uq_household_barcode'),
    )

This enforces "one item per barcode per household" at the DB level, which matches the expected behavior in Flow 1 step 5 of the spec ("If existing product: show current batches...").

If you don't add this constraint, you'll need to handle duplicate barcodes in the API layer, which is trickier.

brand: str | None = Field(default=None)
image_url: str | None = Field(default=None)
# Nullable: category may be unknown if barcode lookup returns no result.
category_id: int | None = Field(default=None, foreign_key="categories.id")
unit: str
notes: str | None = None
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False),
)
# onupdate fires automatically on every ORM-level UPDATE to this row.
updated_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=sa.Column(
sa.DateTime(timezone=True),
nullable=False,
onupdate=lambda: datetime.now(UTC),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best practice note on onupdate:

The onupdate parameter in SQLAlchemy fires on every ORM update, which is great! However, there's a subtle gotcha: it only fires when you use the ORM (like session.add(item) then session.commit()). If you ever use raw SQL updates or bulk operations, this won't trigger.

For learning purposes: in production apps, some teams prefer database-level triggers or DEFAULT CURRENT_TIMESTAMP ON UPDATE (MySQL) to guarantee the timestamp updates regardless of how the row is modified. But for this project, the ORM approach is perfect — just something to be aware of if you ever work on larger systems.

Your implementation here is textbook correct! 👍

),
)
11 changes: 11 additions & 0 deletions backend/app/models/location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import sqlalchemy as sa
from sqlmodel import Field, SQLModel


class Location(SQLModel, table=True):
__tablename__ = "locations" # type: ignore[assignment]
__table_args__ = (sa.UniqueConstraint("household_id", "name", name="uq_location_household_name"),)

id: int | None = Field(default=None, primary_key=True)
household_id: int = Field(foreign_key="households.id", index=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pattern here!

Same as Category — household_id with index is exactly right. Locations are properly isolated per household.

One thing to consider: Should location names be unique within a household? E.g., prevent two "Fridge" locations?

If yes, add:

from sqlalchemy import UniqueConstraint

__table_args__ = (
    UniqueConstraint('household_id', 'name', name='uq_household_location'),
)

This is a UX question: do you want to prevent duplicates, or allow users to have "Fridge 1" and "Fridge 2" if they want? Either is valid — just something to decide explicitly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

name: str
31 changes: 20 additions & 11 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,30 @@ The product — what something *is*, not how much you have.
| household_id | int | FK → Household |
| name | str | |
| barcode | str | Optional, from scan |
| brand | str | Optional, from Open Food Facts |
| image_url | str | Optional, from Open Food Facts |
| category_id | int | FK → Category |
| location_id | int | FK → Location, editable |
| unit | str | e.g. "pieces", "bottles", "kg" |
| notes | str | Optional |
| created_at | datetime | |
| updated_at | datetime | |

**Note:** `location_id` is not on Item — location is tracked per Batch, since the same product can be stored in multiple places at once.

### `Batch`
One entry per purchase of an item. Holds quantity and expiry.
One group of units sharing the same location and expiry date.
| Field | Type | Notes |
|---|---|---|
| id | int | Primary key |
| item_id | int | FK → Item |
| location_id | int | FK → Location |
| quantity | int | |
| expiry_date | date | Month + year precision, defaults to +12 months |
| created_at | datetime | |

**Why Batch exists:** The same product (e.g. pasta) can be bought multiple times with different expiry dates. Each purchase is a separate batch.
**Why Batch exists:** The same product can be bought multiple times with different expiry dates, and/or stored across multiple locations. Each unique combination of location + expiry = one batch.

**Real-world example:** Buy 6 passatas → create two batches: `{quantity: 4, location: cellar, expiry: June 2027}` and `{quantity: 2, location: pantry, expiry: June 2027}`. Inventory view shows total = 6, broken down by location.

**Alert logic:** If total quantity across all batches for an item = 0 → flag for shopping list (v2).

Expand All @@ -140,15 +146,16 @@ One entry per purchase of an item. Holds quantity and expiry.
1. User taps **Scan** on home screen
2. Last used location is prefilled
3. Camera opens, user scans barcode (ZXing-js)
4. App queries Open Food Facts → prefills name, category
5. **If existing product:** show current batches + quantities, user can add new batch or adjust existing
4. App queries Open Food Facts → prefills name, brand, image, category
5. **If existing product:** show current batches + quantities (grouped by location), user can add new batch or adjust existing
6. **If new product:** form shown with prefilled name/category, user enters quantity + unit, expiry defaults to current month + 12 months (month/year picker), all fields editable
7. Save → written to DB
7. **Split by location:** user can tap "Add another location" to create a second batch entry for the same purchase (e.g. 4 to cellar, 2 to pantry)
8. Save → written to DB

### Flow 2: Inventory view
1. List of all items with total quantity and location
1. List of all items with total quantity across all locations
2. Searchable/filterable by: name, location, expiry date
3. Tap item → detail view showing all batches
3. Tap item → detail view showing all batches (each with location, quantity, expiry)
4. From detail: edit any field, adjust quantity per batch (+1 / -1 or type number)

### Flow 3: Expiring soon
Expand Down Expand Up @@ -275,9 +282,11 @@ Registration forks into two paths depending on whether an invite token is presen
| Notifications | None in v1 | Filtered list in app; agent emails in v2 |
| Frontend | React PWA (Vite) | Known stack, mobile-friendly, installable |
| Barcode scanning | ZXing-js | Browser-based, no native app needed |
| Image upload | Not in v1 | Unnecessary complexity |
| Image upload | Not in v1 | Unnecessary complexity; image_url from OFF is sufficient |
| Multi-household user | One household per user | Simpler for v1; invite flow in v2 handles joining an existing household without changing this constraint |
| Location on Batch not Item | location_id on Batch | Same product can live in multiple locations (e.g. cellar + pantry); batch = unique combo of location + expiry |
| Location default | Last used location | Lowest friction for scanning session |
| brand + image_url on Item | Added in v1 | Single fields, zero complexity; brand disambiguates products; retrofitting requires migration |

---

Expand All @@ -289,7 +298,7 @@ Registration forks into two paths depending on whether an invite token is presen
- Shopping list UI
- Agent / email summaries
- Recipe suggestions
- Image upload
- Image upload (image_url from Open Food Facts only)
- Multi-household per user
- Invite flow UI (backend design is specced in section 6, build in v2)

Expand All @@ -307,4 +316,4 @@ Registration forks into two paths depending on whether an invite token is presen

---

*Last updated: May 2026. Built with FastAPI + SQLModel + React PWA.*
*Last updated: June 2026. Built with FastAPI + SQLModel + React PWA.*