diff --git a/api/.github/workflows/ci.yml b/api/.github/workflows/ci.yml new file mode 100644 index 0000000..5c61bb7 --- /dev/null +++ b/api/.github/workflows/ci.yml @@ -0,0 +1,164 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: cartsnitch/api + +jobs: + lint: + runs-on: runners-cartsnitch + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install ruff + - name: Ruff lint + run: ruff check . + - name: Ruff format check + run: ruff format --check . + + typecheck: + runs-on: runners-cartsnitch + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential + - name: Install cartsnitch-common from GitHub + run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git" + - run: pip install -e ".[dev]" mypy + - name: Type check + run: mypy src/cartsnitch_api + + test: + runs-on: runners-cartsnitch + services: + postgres: + image: postgres:15-alpine + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + POSTGRES_USER: cartsnitch + POSTGRES_PASSWORD: cartsnitch_test + POSTGRES_DB: cartsnitch_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + CARTSNITCH_DATABASE_URL: postgresql+asyncpg://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test + CARTSNITCH_REDIS_URL: redis://localhost:6379/0 + CARTSNITCH_JWT_SECRET_KEY: test-secret-do-not-use-in-prod + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential + - name: Install cartsnitch-common from GitHub + run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git" + - run: pip install -e ".[dev]" + - name: Run tests + run: pytest --tb=short -q + + build-and-push: + runs-on: runners-cartsnitch + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate CalVer tag + id: calver + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + DATE_TAG=$(date -u +%Y.%m.%d) + EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1) + if [ -z "$EXISTING" ]; then + VERSION="$DATE_TAG" + elif [ "$EXISTING" = "v${DATE_TAG}" ]; then + VERSION="${DATE_TAG}.2" + else + BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//") + VERSION="${DATE_TAG}.$((BUILD_NUM + 1))" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "CalVer tag: $VERSION" + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=sha- + type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + target: prod + + - name: Create git tag + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git tag "v${{ steps.calver.outputs.version }}" + git push origin "v${{ steps.calver.outputs.version }}" \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 7c3df44..bb5d3bd 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,3 @@ -# Stage 1: Build dependencies -# Build context is the repo root. Paths below are relative to the root. FROM python:3.12-slim AS build RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -8,21 +6,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY api/pyproject.toml ./ -COPY api/src/ ./src/ +COPY pyproject.toml ./ +COPY src/ ./src/ RUN pip install --no-cache-dir --prefix=/install . -# Stage 2: Production image FROM python:3.12-slim AS prod -RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* - WORKDIR /app RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local -COPY api/src/ ./src/ -COPY api/alembic.ini ./ -COPY api/alembic/ ./alembic/ +COPY src/ ./src/ USER 1000 EXPOSE 8000 @@ -30,4 +23,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" -CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"] \ No newline at end of file +CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/api/alembic/versions/005_add_email_inbound_token.py b/api/alembic/versions/005_add_email_inbound_token.py new file mode 100644 index 0000000..4fb7c2c --- /dev/null +++ b/api/alembic/versions/005_add_email_inbound_token.py @@ -0,0 +1,49 @@ +"""Add email_inbound_token to users. + +Revision ID: 005_add_email_inbound_token +Revises: 004_fix_user_id_text +Create Date: 2026-04-02 +""" + +import secrets + +import sqlalchemy as sa + +from alembic import op + +revision = "005_add_email_inbound_token" +down_revision = "004_fix_user_id_text" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add column nullable first so existing rows can be backfilled + op.add_column( + "users", + sa.Column("email_inbound_token", sa.String(22), nullable=True), + ) + + # Backfill existing users with unique tokens + connection = op.get_bind() + result = connection.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) + for (user_id,) in result: + token = secrets.token_urlsafe(16) + connection.execute( + sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"), + {"token": token, "id": user_id}, + ) + + # Now enforce non-null and unique + op.alter_column("users", "email_inbound_token", nullable=False) + op.create_index( + "ix_users_email_inbound_token", + "users", + ["email_inbound_token"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_users_email_inbound_token", table_name="users") + op.drop_column("users", "email_inbound_token") diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index a3735eb..6fe1db4 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly. """ from datetime import UTC, datetime - from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text @@ -18,27 +17,18 @@ from cartsnitch_api.database import get_db # but we support Bearer tokens for service-to-service or mobile clients. bearer_scheme = HTTPBearer(auto_error=False) -# Better-Auth session cookie names. -# Over HTTPS Better-Auth adds the __Secure- prefix automatically. -SESSION_COOKIE_NAMES = [ - "__Secure-better-auth.session_token", # HTTPS (deployed) - "better-auth.session_token", # HTTP (local dev) -] +# Better-Auth session cookie name +SESSION_COOKIE_NAME = "better-auth.session_token" async def _validate_session_token(token: str, db: AsyncSession) -> str: """Validate a Better-Auth session token against the sessions table. Returns the user_id (as str) if the session is valid and not expired. - Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie - is signed: ``rawToken.base64HMACSignature``. Strip the signature - before querying. """ - # Signed cookie format: rawToken.hmacSignature — split and use only the token part - raw_token = token.split(".")[0] if "." in token else token result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), - {"token": raw_token}, + {"token": token}, ) row = result.first() @@ -75,12 +65,8 @@ async def get_current_user( """ token: str | None = None - # 1. Check session cookie (try both names for HTTP/HTTPS compatibility) - cookie_token = None - for name in SESSION_COOKIE_NAMES: - cookie_token = request.cookies.get(name) - if cookie_token: - break + # 1. Check session cookie + cookie_token = request.cookies.get(SESSION_COOKIE_NAME) if cookie_token: token = cookie_token diff --git a/api/src/cartsnitch_api/auth/jwt.py b/api/src/cartsnitch_api/auth/jwt.py index 4e127bc..100c77b 100644 --- a/api/src/cartsnitch_api/auth/jwt.py +++ b/api/src/cartsnitch_api/auth/jwt.py @@ -2,21 +2,22 @@ from datetime import UTC, datetime, timedelta from typing import Any, cast +from uuid import UUID from jose import JWTError, jwt from cartsnitch_api.config import settings -def create_access_token(user_id: str) -> str: +def create_access_token(user_id: UUID) -> str: expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes) - payload = {"sub": user_id, "exp": expire, "type": "access"} + payload = {"sub": str(user_id), "exp": expire, "type": "access"} return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)) -def create_refresh_token(user_id: str) -> str: +def create_refresh_token(user_id: UUID) -> str: expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days) - payload = {"sub": user_id, "exp": expire, "type": "refresh"} + payload = {"sub": str(user_id), "exp": expire, "type": "refresh"} return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)) diff --git a/api/src/cartsnitch_api/auth/routes.py b/api/src/cartsnitch_api/auth/routes.py index 2c547a4..1400d7a 100644 --- a/api/src/cartsnitch_api/auth/routes.py +++ b/api/src/cartsnitch_api/auth/routes.py @@ -6,10 +6,13 @@ endpoints that query our own user data from the shared database. """ from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from cartsnitch_api.auth.dependencies import get_current_user from cartsnitch_api.database import get_db +from cartsnitch_api.models import User from cartsnitch_api.schemas import ( UpdateUserRequest, UserResponse, @@ -19,6 +22,11 @@ from cartsnitch_api.services.auth import AuthService router = APIRouter(prefix="/auth", tags=["auth"]) +class EmailInAddressResponse(BaseModel): + email_address: str + instructions: str + + @router.get("/me", response_model=UserResponse) async def get_me( user_id: str = Depends(get_current_user), @@ -62,3 +70,23 @@ async def delete_me( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) from None + + +@router.get("/me/email-in-address", response_model=EmailInAddressResponse) +async def get_email_in_address( + user_id: str = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User.email_inbound_token).where(User.id == user_id)) + token = result.scalar_one_or_none() + if not token: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found" + ) from None + return EmailInAddressResponse( + email_address=f"receipts+{token}@receipts.cartsnitch.com", + instructions=( + "Forward your digital receipt emails to this address. " + "We currently support Meijer, Kroger, and Target receipt emails." + ), + ) diff --git a/api/src/cartsnitch_api/models/coupon.py b/api/src/cartsnitch_api/models/coupon.py index eb230ea..df2630a 100644 --- a/api/src/cartsnitch_api/models/coupon.py +++ b/api/src/cartsnitch_api/models/coupon.py @@ -9,14 +9,14 @@ from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import DiscountType -from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin +from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from cartsnitch_api.models.product import NormalizedProduct from cartsnitch_api.models.store import Store -class Coupon(UUIDPrimaryKeyMixin, Base): +class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, Base): """A coupon or deal for a product at a store.""" __tablename__ = "coupons" diff --git a/api/src/cartsnitch_api/models/price.py b/api/src/cartsnitch_api/models/price.py index 47373dd..7da0fa6 100644 --- a/api/src/cartsnitch_api/models/price.py +++ b/api/src/cartsnitch_api/models/price.py @@ -9,7 +9,7 @@ from sqlalchemy import Date, ForeignKey, Index, Numeric, String from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import PriceSource -from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin +from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from cartsnitch_api.models.product import NormalizedProduct @@ -17,7 +17,7 @@ if TYPE_CHECKING: from cartsnitch_api.models.store import Store -class PriceHistory(UUIDPrimaryKeyMixin, Base): +class PriceHistory(UUIDPrimaryKeyMixin, TimestampMixin, Base): """A single price observation for a product at a store on a date.""" __tablename__ = "price_history" diff --git a/api/src/cartsnitch_api/models/purchase.py b/api/src/cartsnitch_api/models/purchase.py index 26aa09b..97f577d 100644 --- a/api/src/cartsnitch_api/models/purchase.py +++ b/api/src/cartsnitch_api/models/purchase.py @@ -18,7 +18,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, mapped_column, relationship -from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin +from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from cartsnitch_api.models.price import PriceHistory @@ -27,13 +27,13 @@ if TYPE_CHECKING: from cartsnitch_api.models.user import User -class Purchase(UUIDPrimaryKeyMixin, Base): +class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): """A single shopping trip / receipt.""" __tablename__ = "purchases" user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) - store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) + store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False) store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id")) receipt_id: Mapped[str] = mapped_column(String(200), nullable=False) purchase_date: Mapped[date] = mapped_column(Date, nullable=False) @@ -61,7 +61,7 @@ class Purchase(UUIDPrimaryKeyMixin, Base): ) -class PurchaseItem(UUIDPrimaryKeyMixin, Base): +class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Individual line item on a receipt.""" __tablename__ = "purchase_items" diff --git a/api/src/cartsnitch_api/models/shrinkflation.py b/api/src/cartsnitch_api/models/shrinkflation.py index 35f5d40..2ce6f9d 100644 --- a/api/src/cartsnitch_api/models/shrinkflation.py +++ b/api/src/cartsnitch_api/models/shrinkflation.py @@ -9,13 +9,13 @@ from sqlalchemy import Date, ForeignKey, Numeric, String from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import SizeUnit -from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin +from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from cartsnitch_api.models.product import NormalizedProduct -class ShrinkflationEvent(UUIDPrimaryKeyMixin, Base): +class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Detected shrinkflation event — product size changed while price held or rose.""" __tablename__ = "shrinkflation_events" diff --git a/api/src/cartsnitch_api/models/user.py b/api/src/cartsnitch_api/models/user.py index 2c87644..89390a3 100644 --- a/api/src/cartsnitch_api/models/user.py +++ b/api/src/cartsnitch_api/models/user.py @@ -1,6 +1,6 @@ """User and UserStoreAccount models.""" -import uuid +import secrets from datetime import datetime from typing import TYPE_CHECKING @@ -25,6 +25,12 @@ class User(TimestampMixin, Base): email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str | None] = mapped_column(String(100)) + email_inbound_token: Mapped[str] = mapped_column( + String(22), + nullable=False, + unique=True, + default=lambda: secrets.token_urlsafe(16), + ) # Relationships store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user") @@ -38,7 +44,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base): __table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),) user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) - store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) + store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False) session_data: Mapped[dict | None] = mapped_column(EncryptedJSON) session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/api/src/cartsnitch_api/routes/alerts.py b/api/src/cartsnitch_api/routes/alerts.py index 9b3fe8f..45ab33f 100644 --- a/api/src/cartsnitch_api/routes/alerts.py +++ b/api/src/cartsnitch_api/routes/alerts.py @@ -1,5 +1,7 @@ """Alert routes: list alerts, manage settings.""" +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +15,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"]) @router.get("", response_model=list[AlertResponse]) async def list_alerts( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AlertService(db) @@ -22,7 +24,7 @@ async def list_alerts( @router.get("/settings", response_model=AlertSettingsResponse) async def get_alert_settings( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AlertService(db) @@ -32,7 +34,7 @@ async def get_alert_settings( @router.put("/settings") async def update_alert_settings( body: AlertSettingsRequest, - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): raise HTTPException( diff --git a/api/src/cartsnitch_api/routes/coupons.py b/api/src/cartsnitch_api/routes/coupons.py index 9e43fbc..d33d98a 100644 --- a/api/src/cartsnitch_api/routes/coupons.py +++ b/api/src/cartsnitch_api/routes/coupons.py @@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"]) @router.get("", response_model=list[CouponResponse]) async def list_coupons( store_id: UUID | None = Query(None), - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = CouponService(db) @@ -25,7 +25,7 @@ async def list_coupons( @router.get("/relevant", response_model=list[CouponResponse]) async def relevant_coupons( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = CouponService(db) diff --git a/api/src/cartsnitch_api/routes/prices.py b/api/src/cartsnitch_api/routes/prices.py index c39a1ce..487dd92 100644 --- a/api/src/cartsnitch_api/routes/prices.py +++ b/api/src/cartsnitch_api/routes/prices.py @@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"]) @router.get("/trends", response_model=list[PriceTrendResponse]) async def price_trends( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), category: str | None = Query(None), db: AsyncSession = Depends(get_db), ): @@ -30,7 +30,7 @@ async def price_trends( @router.get("/increases", response_model=list[PriceIncreaseResponse]) async def price_increases( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PriceService(db) @@ -40,7 +40,7 @@ async def price_increases( @router.get("/comparison", response_model=list[PriceComparisonResponse]) async def price_comparison( product_ids: Annotated[list[UUID], Query()], - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PriceService(db) diff --git a/api/src/cartsnitch_api/routes/products.py b/api/src/cartsnitch_api/routes/products.py index 84205e8..473cefe 100644 --- a/api/src/cartsnitch_api/routes/products.py +++ b/api/src/cartsnitch_api/routes/products.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"]) @router.get("", response_model=list[ProductResponse]) async def list_products( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), q: str | None = Query(None), category: str | None = Query(None), page: int = Query(1, ge=1), @@ -29,7 +29,7 @@ async def list_products( @router.get("/{product_id}", response_model=ProductDetailResponse) async def get_product( product_id: UUID, - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = ProductService(db) @@ -44,7 +44,7 @@ async def get_product( @router.get("/{product_id}/prices", response_model=PriceTrendResponse) async def get_product_prices( product_id: UUID, - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = ProductService(db) diff --git a/api/src/cartsnitch_api/routes/purchases.py b/api/src/cartsnitch_api/routes/purchases.py index a337c8e..eba86ac 100644 --- a/api/src/cartsnitch_api/routes/purchases.py +++ b/api/src/cartsnitch_api/routes/purchases.py @@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"]) @router.get("", response_model=list[PurchaseResponse]) async def list_purchases( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), store_id: UUID | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), @@ -27,7 +27,7 @@ async def list_purchases( @router.get("/stats", response_model=PurchaseStatsResponse) async def purchase_stats( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PurchaseService(db) @@ -37,7 +37,7 @@ async def purchase_stats( @router.get("/{purchase_id}", response_model=PurchaseDetailResponse) async def get_purchase( purchase_id: UUID, - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = PurchaseService(db) diff --git a/api/src/cartsnitch_api/routes/scraping.py b/api/src/cartsnitch_api/routes/scraping.py index 2804212..d8bbd5f 100644 --- a/api/src/cartsnitch_api/routes/scraping.py +++ b/api/src/cartsnitch_api/routes/scraping.py @@ -1,5 +1,7 @@ """Scraping routes: trigger sync, check status (proxy to ReceiptWitness).""" +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError, RequestError @@ -11,7 +13,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"]) @router.post("/{store_slug}/sync", response_model=SyncTriggerResponse) -async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)): +async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)): client = ReceiptWitnessClient() try: result = await client.trigger_sync(str(user_id), store_slug) @@ -29,7 +31,7 @@ async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user) @router.get("/status", response_model=list[SyncStatusResponse]) -async def sync_status(user_id: str = Depends(get_current_user)): +async def sync_status(user_id: UUID = Depends(get_current_user)): client = ReceiptWitnessClient() try: return await client.get_sync_status(str(user_id)) diff --git a/api/src/cartsnitch_api/routes/shopping.py b/api/src/cartsnitch_api/routes/shopping.py index f7c3d0e..c64d5fd 100644 --- a/api/src/cartsnitch_api/routes/shopping.py +++ b/api/src/cartsnitch_api/routes/shopping.py @@ -1,5 +1,7 @@ """Shopping routes: optimize list, saved lists.""" +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status from httpx import HTTPStatusError, RequestError @@ -11,7 +13,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"]) @router.post("/optimize", response_model=OptimizeResponse) -async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)): +async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)): client = ClipArtistClient() try: result = await client.optimize( @@ -35,7 +37,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_cu @router.get("/lists", response_model=list[ShoppingListResponse]) -async def list_shopping_lists(user_id: str = Depends(get_current_user)): +async def list_shopping_lists(user_id: UUID = Depends(get_current_user)): client = ClipArtistClient() try: return await client.get_shopping_lists(str(user_id)) diff --git a/api/src/cartsnitch_api/routes/stores.py b/api/src/cartsnitch_api/routes/stores.py index 1525933..1ab7947 100644 --- a/api/src/cartsnitch_api/routes/stores.py +++ b/api/src/cartsnitch_api/routes/stores.py @@ -1,5 +1,7 @@ """Store routes: list stores, manage user store connections.""" +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -19,7 +21,7 @@ async def list_stores(db: AsyncSession = Depends(get_db)): @router.get("/me/stores", response_model=list[StoreAccountResponse]) async def list_user_stores( - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) @@ -34,7 +36,7 @@ async def list_user_stores( async def connect_store( store_slug: str, body: ConnectStoreRequest, - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) @@ -49,7 +51,7 @@ async def connect_store( @router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT) async def disconnect_store( store_slug: str, - user_id: str = Depends(get_current_user), + user_id: UUID = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = StoreService(db) diff --git a/api/src/cartsnitch_api/schemas.py b/api/src/cartsnitch_api/schemas.py index 42fc7c6..68e1dbe 100644 --- a/api/src/cartsnitch_api/schemas.py +++ b/api/src/cartsnitch_api/schemas.py @@ -1,7 +1,6 @@ """Pydantic v2 request/response schemas for all API endpoints.""" -from datetime import date, datetime -from uuid import UUID +from datetime import datetime from pydantic import BaseModel, EmailStr, Field @@ -60,7 +59,7 @@ class PurchaseResponse(BaseModel): id: UUID store_id: UUID store_name: str - purchased_at: date + purchased_at: datetime total: float item_count: int @@ -142,7 +141,7 @@ class CouponResponse(BaseModel): discount_value: float discount_type: str product_id: UUID | None = None - expires_at: date | None = None + expires_at: datetime | None = None # ---------- Shopping ---------- diff --git a/api/src/cartsnitch_api/services/alerts.py b/api/src/cartsnitch_api/services/alerts.py index cc03d60..fc3ddd4 100644 --- a/api/src/cartsnitch_api/services/alerts.py +++ b/api/src/cartsnitch_api/services/alerts.py @@ -4,6 +4,8 @@ Alerts are generated by StickerShock and ShrinkRay services and written to the D This service reads them for the API gateway. """ +from uuid import UUID + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -13,7 +15,7 @@ class AlertService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def list_alerts(self, user_id: str) -> list[dict]: + async def list_alerts(self, user_id: UUID) -> list[dict]: """List shrinkflation events for products the user has purchased.""" from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent @@ -55,7 +57,7 @@ class AlertService: for e in events ] - async def get_settings(self, user_id: str) -> dict: + async def get_settings(self, user_id: UUID) -> dict: # Alert settings would be stored in a user_settings table. # For now, return defaults since the table doesn't exist yet in common lib. return { @@ -64,7 +66,7 @@ class AlertService: "email_notifications": False, } - async def update_settings(self, user_id: str, **fields) -> dict: + async def update_settings(self, user_id: UUID, **fields) -> dict: # Would update user_settings table. Return merged defaults for now. current = await self.get_settings(user_id) for k, v in fields.items(): diff --git a/api/src/cartsnitch_api/services/coupons.py b/api/src/cartsnitch_api/services/coupons.py index a5b8a2c..9b1543e 100644 --- a/api/src/cartsnitch_api/services/coupons.py +++ b/api/src/cartsnitch_api/services/coupons.py @@ -29,7 +29,7 @@ class CouponService: coupons = result.scalars().all() return [self._to_dict(c) for c in coupons] - async def relevant_coupons(self, user_id: str) -> list[dict]: + async def relevant_coupons(self, user_id: UUID) -> list[dict]: """Coupons for products the user has purchased.""" from cartsnitch_api.models import Coupon, PurchaseItem diff --git a/api/src/cartsnitch_api/services/purchases.py b/api/src/cartsnitch_api/services/purchases.py index 10ca0a4..41776f4 100644 --- a/api/src/cartsnitch_api/services/purchases.py +++ b/api/src/cartsnitch_api/services/purchases.py @@ -13,7 +13,7 @@ class PurchaseService: async def list_purchases( self, - user_id: str, + user_id: UUID, store_id: UUID | None = None, page: int = 1, page_size: int = 20, @@ -56,7 +56,7 @@ class PurchaseService: for p, item_count, store_name in result.all() ] - async def get_purchase(self, purchase_id: UUID, user_id: str) -> dict: + async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict: from cartsnitch_api.models import Purchase result = await self.db.execute( @@ -88,7 +88,7 @@ class PurchaseService: ], } - async def get_stats(self, user_id: str) -> dict: + async def get_stats(self, user_id: UUID) -> dict: from cartsnitch_api.models import Purchase result = await self.db.execute( diff --git a/api/src/cartsnitch_api/services/stores.py b/api/src/cartsnitch_api/services/stores.py index c7d43ec..610f47e 100644 --- a/api/src/cartsnitch_api/services/stores.py +++ b/api/src/cartsnitch_api/services/stores.py @@ -1,6 +1,7 @@ """Store service — list stores, manage user store account connections.""" import json +from uuid import UUID from cryptography.fernet import Fernet from sqlalchemy import select @@ -34,7 +35,7 @@ class StoreService: for s in stores ] - async def list_user_stores(self, user_id: str) -> list[dict]: + async def list_user_stores(self, user_id: UUID) -> list[dict]: from cartsnitch_api.models import UserStoreAccount result = await self.db.execute( @@ -59,7 +60,7 @@ class StoreService: for a in accounts ] - async def connect_store(self, user_id: str, store_slug: str, credentials: dict | None) -> dict: + async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict: from cartsnitch_api.models import Store, UserStoreAccount result = await self.db.execute(select(Store).where(Store.slug == store_slug)) @@ -106,7 +107,7 @@ class StoreService: "sync_status": "active", } - async def disconnect_store(self, user_id: str, store_slug: str) -> None: + async def disconnect_store(self, user_id: UUID, store_slug: str) -> None: from cartsnitch_api.models import Store, UserStoreAccount result = await self.db.execute(select(Store).where(Store.slug == store_slug)) diff --git a/api/tests/test_e2e/test_email_in_address.py b/api/tests/test_e2e/test_email_in_address.py new file mode 100644 index 0000000..7886572 --- /dev/null +++ b/api/tests/test_e2e/test_email_in_address.py @@ -0,0 +1,61 @@ +"""Tests for GET /auth/me/email-in-address endpoint.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict): + """Authenticated user gets their email-in address.""" + response = await client.get( + "/auth/me/email-in-address", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "email_address" in data + assert data["email_address"].startswith("receipts+") + assert data["email_address"].endswith("@receipts.cartsnitch.com") + assert len(data["email_address"]) > len("receipts+@receipts.cartsnitch.com") + assert "instructions" in data + assert "Meijer" in data["instructions"] + assert "Kroger" in data["instructions"] + assert "Target" in data["instructions"] + + +@pytest.mark.asyncio +async def test_get_email_in_address_unauthenticated(client: AsyncClient): + """Unauthenticated request returns 401.""" + response = await client.get("/auth/me/email-in-address") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_email_in_address_invalid_token(client: AsyncClient): + """Invalid JWT token returns 401.""" + response = await client.get( + "/auth/me/email-in-address", + headers={"Authorization": "Bearer invalid-token-xyz"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_email_address_format(client: AsyncClient, auth_headers: dict): + """Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com.""" + response = await client.get( + "/auth/me/email-in-address", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + email = data["email_address"] + # Format: receipts+<22-char-urlsafe-token>@receipts.cartsnitch.com + assert email.startswith("receipts+") + assert email.endswith("@receipts.cartsnitch.com") + # token_urlsafe(16) produces 22 chars + middle = email[len("receipts+") : -len("@receipts.cartsnitch.com")] + assert len(middle) == 22 + assert "@" not in middle diff --git a/api/tests/test_openapi.py b/api/tests/test_openapi.py index 97eef19..7379f84 100644 --- a/api/tests/test_openapi.py +++ b/api/tests/test_openapi.py @@ -6,13 +6,14 @@ from httpx import ASGITransport, AsyncClient from cartsnitch_api.main import app EXPECTED_ROUTES = [ - # Auth (6) + # Auth (7) ("post", "/auth/register"), ("post", "/auth/login"), ("post", "/auth/refresh"), ("get", "/auth/me"), ("patch", "/auth/me"), ("delete", "/auth/me"), + ("get", "/auth/me/email-in-address"), # Stores (4) ("get", "/stores"), ("get", "/me/stores"), @@ -89,4 +90,4 @@ async def test_route_count(): if method in ("get", "post", "put", "delete", "patch"): count += 1 - assert count == 33, f"Expected 33 routes, found {count}" + assert count == 34, f"Expected 34 routes, found {count}"