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
51 changes: 51 additions & 0 deletions .claude/commands/til.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# TIL — Today I Learned

Update `docs/til.md` with learnings from the current session.

## Steps

1. Read `docs/til.md` to understand what's already there.

2. Review recent work — look at the git diff, recent commits, and the current conversation to identify:
- New patterns or concepts introduced
- Errors that were hit and why they happened
- Design decisions made and the reasoning
- Gotchas or non-obvious behaviour

3. Add new content to the appropriate sections. Rules:
- **Don't duplicate.** If a concept is already covered, skip it or add a sub-point only if it adds something new.
- **Add to existing sections** rather than creating new ones unless it's genuinely new territory.
- **One concept per bullet.** Keep entries concise — a sentence or two max.
- **Ground it in the codebase.** Reference the actual file/pattern when helpful.

4. Add a new entry to the **Session Log** at the bottom:
- Today's date
- What was built (one line)
- Key decisions or errors fixed (bullet list)

5. Show the user a summary of what was added — list the section and the new bullet(s) so they can review and ask for changes before accepting.

## What to look for

**Patterns worth capturing:**
- New language or framework features used
- Query patterns, ORM tricks, async gotchas
- Security decisions (auth, isolation, validation)
- Type system gotchas

**Errors worth capturing:**
- Type errors and why they happened
- Linter rule violations and what they mean
- Runtime errors hit during testing

**Design decisions worth capturing:**
- Any time we chose between two approaches and had a reason
- Any time a simpler approach was rejected
- API contract decisions (status codes, field names, optional vs required)
- Wording decisions on error messages (yes, those count)

## Important

Keep the tone clear and direct — write as if explaining to a senior developer.
Do not add fluff, do not pad entries, do not repeat things already covered.
This is a learning log, not a changelog — capture the *why*, not just the *what*.
32 changes: 31 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Household → User (many per household)

- **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.
- Expiry is stored as a full `date` field; the UI will show month + year only. Defaults to +12 months from today.
- `ShoppingListItem` model exists in v1 but has no UI — that's a v2 feature.

### Multi-household data isolation
Expand Down Expand Up @@ -89,6 +89,35 @@ All `datetime` fields must use `sa_column=sa.Column(sa.DateTime(timezone=True))`

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.

### SQLModel query conventions

**`col()` for column expressions:** When using `.ilike()`, `.in_()`, `.desc()`, or `order_by()` on a model attribute, wrap it with `col()` from `sqlmodel`. Without it, Pyright resolves the attribute as a Python type (`str`, `int`, `date`) and flags the SQL method as unknown.

```python
from sqlmodel import col, select

select(Item).where(col(Item.name).ilike(f"%{name}%"))
select(Item).where(col(Item.id).in_(subquery))
select(Batch).order_by(col(Batch.expiry_date))
```

**`AsyncSession` import:** Always import `AsyncSession` from `sqlmodel.ext.asyncio.session`, not from `sqlalchemy.ext.asyncio`. SQLAlchemy's version doesn't have `.exec()`. Also set `class_=AsyncSession` in `async_sessionmaker` so the session factory creates SQLModel sessions:

```python
from sqlmodel.ext.asyncio.session import AsyncSession
_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
```

### API endpoint conventions

**Household isolation:** Every query must filter by `user.household_id`. Return 404 for both "not found" and "wrong household" — never 403. Returning 403 leaks whether an ID exists.

**IntegrityError handling:** Let the DB enforce unique constraints rather than doing pre-check queries (race condition + extra round-trip). Catch `sqlalchemy.exc.IntegrityError` after `commit()`, call `session.rollback()`, and raise a 409. Do not attempt to introspect the underlying driver exception (`exc.orig.pgcode`, `isinstance(..., asyncpg.UniqueViolationError)`) — this is fragile across SQLAlchemy/asyncpg versions. Add a comment explaining why the broad catch is safe for that specific endpoint.

**Response schemas:** Never return raw table models — they expose `household_id` and other internals. Always return a `*Read` schema that explicitly lists the fields the client should see.

**PATCH pattern:** Use `data.model_dump(exclude_unset=True)` to only update fields explicitly sent in the request. This prevents overwriting existing values with `None` for fields the client didn't mention.

### 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.
Expand All @@ -99,6 +128,7 @@ Import `SQLAlchemyUserDatabase` directly from `fastapi_users_db_sqlalchemy`, not
- `./scripts/fetch-pr-comments.sh` — fetches open PR review comments into `docs/pr-comments.md`
- `/pr-description` — generates a PR description and saves to `docs/pr-description.md`
- `/review-comments` — works through `docs/pr-comments.md` one comment at a time
- `/til` — updates `docs/til.md` with learnings from the current session

## Developer environment

Expand Down
5 changes: 4 additions & 1 deletion backend/app/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.db import BaseUserDatabase
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel.ext.asyncio.session import AsyncSession

from app.auth.backend import auth_backend
from app.config import get_settings
Expand Down Expand Up @@ -49,6 +49,9 @@ async def on_after_register(
household = Household(name=f"{prefix}'s household")
self.session.add(household)
await self.session.flush() # writes household row and populates household.id
# flush always populates the PK; the None check is for type narrowing only.
if household.id is None:
raise RuntimeError("Household PK not populated after flush")
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"))
Expand Down
7 changes: 5 additions & 2 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from collections.abc import AsyncGenerator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession

from .config import get_settings

Expand All @@ -14,7 +15,9 @@
engine = create_async_engine(DATABASE_URL, echo=settings.environment == "development", pool_pre_ping=True)

# keeps objects usable after commit (relevant for async sessions)
_session_factory = async_sessionmaker(engine, expire_on_commit=False)
# class_=AsyncSession uses SQLModel's session subclass, which adds the .exec() method
# that Pylance/Pyright can resolve. SQLAlchemy's default AsyncSession doesn't have it.
_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


async def get_session() -> AsyncGenerator[AsyncSession]:
Expand Down
7 changes: 6 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.auth.users import fastapi_users
from app.database import create_db_and_tables
from app.models.user import UserCreate, UserRead
from app.routers import household
from app.routers import batches, categories, household, items, locations, lookup


@asynccontextmanager
Expand All @@ -34,6 +34,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
)

app.include_router(household.router)
app.include_router(locations.router)
app.include_router(categories.router)
app.include_router(items.router)
app.include_router(batches.router)
app.include_router(lookup.router)


@app.get("/health")
Expand Down
5 changes: 5 additions & 0 deletions backend/app/models/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

class Batch(SQLModel, table=True):
__tablename__ = "batches" # type: ignore[assignment]
# Enforces the core invariant: one batch per unique (item, location, expiry) combo.
# Prevents duplicate batches from being created directly or via the location move flow.
__table_args__ = (
sa.UniqueConstraint("item_id", "location_id", "expiry_date", name="uq_batch_item_location_expiry"),
)

id: int | None = Field(default=None, primary_key=True)
# Denormalized from Item for query-level household isolation — every batch query can filter
Expand Down
Loading