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
36 changes: 32 additions & 4 deletions backend/app/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from collections.abc import AsyncGenerator
from typing import Annotated

from fastapi import Depends
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.db import BaseUserDatabase
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
Expand All @@ -11,6 +11,7 @@
from app.auth.backend import auth_backend
from app.config import get_settings
from app.database import get_session
from app.models.household import Household
from app.models.user import User


Expand All @@ -23,16 +24,43 @@ async def get_user_db(


class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# These secrets sign password-reset and verification tokens.
# Changing them invalidates all outstanding tokens.
reset_password_token_secret = get_settings().secret_key.get_secret_value()
verification_token_secret = get_settings().secret_key.get_secret_value()

def __init__(
self,
user_db: BaseUserDatabase[User, uuid.UUID],
session: AsyncSession,
) -> None:
super().__init__(user_db)
self.session = session

async def on_after_register(
self, user: User, request: Request | None = None
) -> None:
# FastAPI Users commits the user row before calling this hook, so true
# atomicity isn't possible. We use a compensating transaction: if
# household creation fails for any reason, we delete the orphaned user
# so the DB is left in a consistent state.
try:
prefix = user.email.split("@", 1)[0].strip() or "User"
household = Household(name=f"{prefix}'s household")
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
await self.session.commit()
except Exception:
await self.user_db.delete(user)
raise


async def get_user_manager(
user_db: Annotated[BaseUserDatabase[User, uuid.UUID], Depends(get_user_db)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> AsyncGenerator[UserManager]:
yield UserManager(user_db)
# FastAPI caches dependencies per request, so session here is the same
# instance already open for get_user_db — no extra connection is opened.
Comment on lines +61 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Great comment! This explains a non-obvious behavior (dependency caching) that could confuse someone reading the code.

This is exactly the kind of comment that adds value — it documents a framework behavior that isn't immediately obvious from the code itself.

yield UserManager(user_db, session)


fastapi_users: FastAPIUsers[User, uuid.UUID] = FastAPIUsers(
Expand Down
4 changes: 3 additions & 1 deletion backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@

DATABASE_URL = settings.database_url.get_secret_value()

engine = create_async_engine(DATABASE_URL, echo=settings.environment == "development")
# pool_pre_ping=True tests each connection before use and reconnects if Neon
# closed it due to inactivity (Neon drops idle connections after ~5 minutes).
engine = create_async_engine(DATABASE_URL, echo=settings.environment == "development", pool_pre_ping=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.

Smart addition for Neon!

pool_pre_ping=True is essential when using serverless databases like Neon that drop idle connections. SQLAlchemy will test each connection with a simple query (SELECT 1) before handing it to your code, and automatically reconnect if it's stale.

The performance overhead is minimal (microseconds), and it prevents cryptic "connection closed" errors in production.

Good operational awareness — this shows you're thinking about how the code will behave in a real deployment environment, not just on your dev machine.


# keeps objects usable after commit (relevant for async sessions)
_session_factory = async_sessionmaker(engine, expire_on_commit=False)
Expand Down
7 changes: 6 additions & 1 deletion backend/app/models/household.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from datetime import UTC, datetime

import sqlalchemy as sa
from sqlmodel import Field, SQLModel


class Household(SQLModel, table=True):
__tablename__: str = "households"

id: int | None = Field(default=None, primary_key=True)
name: str
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False),
)
Comment on lines +12 to +15

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 timezone-aware improvement here — great consistency across models.

9 changes: 6 additions & 3 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from datetime import UTC, datetime

import sqlalchemy as sa
from fastapi_users import schemas
from sqlmodel import Field, SQLModel

Expand All @@ -18,11 +19,13 @@ class User(SQLModel, table=True):
is_active: bool = Field(default=True)
is_superuser: bool = Field(default=False)
is_verified: bool = Field(default=False)
# Nullable until the on_after_register hook creates and assigns a household.
household_id: int | None = Field(
default=None, foreign_key="household.id", index=True
default=None, 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.

Excellent catch!

The foreign key must reference the table name ("households") not the model name ("household"). This was a bug that would cause runtime errors when SQLModel tried to create the foreign key constraint.

Good attention to detail.

)
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=sa.Column(sa.DateTime(timezone=True), nullable=False),
)
Comment on lines +25 to 28

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 improvement!

Using sa.Column(sa.DateTime(timezone=True)) ensures the database column stores timezone-aware timestamps. This is critical for multi-timezone applications.

Without timezone=True, PostgreSQL stores timestamps as TIMESTAMP WITHOUT TIME ZONE, which loses timezone information and can cause subtle bugs when users are in different timezones.

With timezone=True, you get TIMESTAMP WITH TIME ZONE (PostgreSQL's timestamptz type), which normalizes everything to UTC internally but preserves timezone semantics.

This is a best practice worth carrying forward to any other datetime fields you add.

created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))


class UserRead(schemas.BaseUser[uuid.UUID]):
Expand Down