Compare commits

...

14 Commits

Author SHA1 Message Date
CartSnitch Engineer Bot c855575e77 fix(api): restore /api/v1 prefix on data routers
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 10:15:21 +00:00
CartSnitch Engineer Bot f721918f95 fix(api): revert auth/type regressions from standalone sync, keep email-in feature only
- Revert auth/dependencies.py to cookie+Bearer dual auth with str user IDs
- Add GET /auth/me/email-in-address endpoint for receipt email routing
- Update User model: add email_inbound_token, change id/store_id/user_id to str
- Update AuthService and UserResponse to use str user IDs
- Update route count test: 33 -> 34 routes
- Restore e2e test for email-in-address endpoint

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 09:40:39 +00:00
CartSnitch Engineer Bot 692f42fbbb fix(auth): revert to Better-Auth session-cookie auth, preserve email-in feature
- Revert auth/dependencies.py, auth/routes.py, services/auth.py, schemas.py
  to Better-Auth session-cookie auth (removed JWT register/login/refresh)
- Preserve GET /auth/me/email-in-address endpoint
- Fix UUIDString TypeDecorator: process_result_value returns uuid.UUID
  (not str) so SQLAlchemy 2.0 sentinel tracking matches UUID-to-UUID
- Fix seed_data fixture: look up real user_id from session token via
  sessions table; purchases now reference actual user FK
- Update purchase_data fixture to use session-cookie auth
- Update test_auth_endpoints, test_auth_validation to cookie-based tests
- Remove TestRegistrationErrors and TestLoginErrors (no longer applicable)
- Update test_openapi.py expected routes and count
- Update test_error_handler.py to use PATCH /auth/me validation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 09:15:00 +00:00
Barcode Betty 70b9d1d6d6 sync(api): copy latest standalone code and merge alembic migrations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-03 07:54:31 +00:00
cartsnitch-ceo[bot] 1b418e7c6f fix(api): change purchased_at and expires_at schema types from datetime to date
fix(api): change purchased_at and expires_at schema types from datetime to date
2026-04-01 23:56:49 +00:00
cartsnitch-ceo[bot] 0b31badbcd Merge branch 'main' into fix/api-date-schema-types 2026-04-01 20:56:13 +00:00
cartsnitch-ceo[bot] eb579dcaa5 fix(frontend): remove hardcoded mock product IDs from Dashboard price trends
fix(frontend): remove hardcoded mock product IDs from Dashboard price trends
2026-04-01 20:25:19 +00:00
cartsnitch-ceo[bot] 086868d450 Merge branch 'main' into fix/dashboard-hardcoded-product-ids 2026-04-01 20:19:22 +00:00
CartSnitch Engineer Bot 63621df0b8 fix(frontend): remove unused React import from Dashboard.tsx
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:58:41 +00:00
cartsnitch-engineer[bot] 41e6bfdcf5 feat(scripts): add dev environment seed script and K8s Job (#99)
* fix(api): replace UUID type with str for Better-Auth nanoid user IDs

Better-Auth uses nanoid strings for user IDs, not UUIDs. Changed all
user_id parameter/return types in the API layer from UUID to str,
removed the obsolete UUID import where unused, and updated the
_validate_session_token return type accordingly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(scripts): add dev environment seed script and K8s Job

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 19:51:45 +00:00
CartSnitch Engineer Bot a60859f22f fix(frontend): remove unused React import from Dashboard.tsx
Removes the unused `import React from 'react'` line from Dashboard.tsx
to resolve TS6133 error in lighthouse CI. No other code in the file
references the React namespace.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:49:14 +00:00
CartSnitch Engineer Bot 8e8d4a4774 fix(api): change purchased_at and expires_at schema types from datetime to date
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:41:35 +00:00
CartSnitch Engineer Bot e85d757cc6 fix(frontend): remove hardcoded mock product IDs from Dashboard price trends
Removed usePriceHistory calls with hardcoded string product IDs (prod1, prod10)
that caused 422 errors against the UUID-expecting API. The Price Trends section
now shows a placeholder until a proper featured-products endpoint is available.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:37:44 +00:00
CartSnitch Engineer Bot 43cb62a4d6 fix(api): remove TimestampMixin from models whose DB tables lack timestamp columns
Remove TimestampMixin (created_at/updated_at) from Purchase, PurchaseItem,
PriceHistory, Coupon, and ShrinkflationEvent models since their PostgreSQL
tables do not have those columns. This was causing 500 errors on
/api/v1/purchases and /api/v1/purchases/stats.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:36:21 +00:00
26 changed files with 538 additions and 102 deletions
+164
View File
@@ -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 }}"
+4 -11
View File
@@ -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 FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \ 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY api/pyproject.toml ./ COPY pyproject.toml ./
COPY api/src/ ./src/ COPY src/ ./src/
RUN pip install --no-cache-dir --prefix=/install . RUN pip install --no-cache-dir --prefix=/install .
# Stage 2: Production image
FROM python:3.12-slim AS prod 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 WORKDIR /app
RUN adduser --system --group --uid 1000 app RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local COPY --from=build /install /usr/local
COPY api/src/ ./src/ COPY src/ ./src/
COPY api/alembic.ini ./
COPY api/alembic/ ./alembic/
USER 1000 USER 1000
EXPOSE 8000 EXPOSE 8000
@@ -30,4 +23,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \ HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" 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"] CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -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")
+5 -19
View File
@@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text 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. # but we support Bearer tokens for service-to-service or mobile clients.
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
# Better-Auth session cookie names. # Better-Auth session cookie name
# Over HTTPS Better-Auth adds the __Secure- prefix automatically. SESSION_COOKIE_NAME = "better-auth.session_token"
SESSION_COOKIE_NAMES = [
"__Secure-better-auth.session_token", # HTTPS (deployed)
"better-auth.session_token", # HTTP (local dev)
]
async def _validate_session_token(token: str, db: AsyncSession) -> str: async def _validate_session_token(token: str, db: AsyncSession) -> str:
"""Validate a Better-Auth session token against the sessions table. """Validate a Better-Auth session token against the sessions table.
Returns the user_id (as str) if the session is valid and not expired. 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( result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": raw_token}, {"token": token},
) )
row = result.first() row = result.first()
@@ -75,12 +65,8 @@ async def get_current_user(
""" """
token: str | None = None token: str | None = None
# 1. Check session cookie (try both names for HTTP/HTTPS compatibility) # 1. Check session cookie
cookie_token = None cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
for name in SESSION_COOKIE_NAMES:
cookie_token = request.cookies.get(name)
if cookie_token:
break
if cookie_token: if cookie_token:
token = cookie_token token = cookie_token
+5 -4
View File
@@ -2,21 +2,22 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any, cast from typing import Any, cast
from uuid import UUID
from jose import JWTError, jwt from jose import JWTError, jwt
from cartsnitch_api.config import settings 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) 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)) 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) 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)) return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
+28
View File
@@ -6,10 +6,13 @@ endpoints that query our own user data from the shared database.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db from cartsnitch_api.database import get_db
from cartsnitch_api.models import User
from cartsnitch_api.schemas import ( from cartsnitch_api.schemas import (
UpdateUserRequest, UpdateUserRequest,
UserResponse, UserResponse,
@@ -19,6 +22,11 @@ from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_me( async def get_me(
user_id: str = Depends(get_current_user), user_id: str = Depends(get_current_user),
@@ -62,3 +70,23 @@ async def delete_me(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None ) 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."
),
)
+1 -1
View File
@@ -33,7 +33,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "purchases" __tablename__ = "purchases"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) 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")) store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False) receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
purchase_date: Mapped[date] = mapped_column(Date, nullable=False) purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
+8 -2
View File
@@ -1,6 +1,6 @@
"""User and UserStoreAccount models.""" """User and UserStoreAccount models."""
import uuid import secrets
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -25,6 +25,12 @@ class User(TimestampMixin, Base):
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100)) 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 # Relationships
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user") 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"),) __table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) 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_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+5 -3
View File
@@ -1,5 +1,7 @@
"""Alert routes: list alerts, manage settings.""" """Alert routes: list alerts, manage settings."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +15,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"])
@router.get("", response_model=list[AlertResponse]) @router.get("", response_model=list[AlertResponse])
async def list_alerts( async def list_alerts(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = AlertService(db) svc = AlertService(db)
@@ -22,7 +24,7 @@ async def list_alerts(
@router.get("/settings", response_model=AlertSettingsResponse) @router.get("/settings", response_model=AlertSettingsResponse)
async def get_alert_settings( async def get_alert_settings(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = AlertService(db) svc = AlertService(db)
@@ -32,7 +34,7 @@ async def get_alert_settings(
@router.put("/settings") @router.put("/settings")
async def update_alert_settings( async def update_alert_settings(
body: AlertSettingsRequest, body: AlertSettingsRequest,
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
raise HTTPException( raise HTTPException(
+2 -2
View File
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"])
@router.get("", response_model=list[CouponResponse]) @router.get("", response_model=list[CouponResponse])
async def list_coupons( async def list_coupons(
store_id: UUID | None = Query(None), 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), db: AsyncSession = Depends(get_db),
): ):
svc = CouponService(db) svc = CouponService(db)
@@ -25,7 +25,7 @@ async def list_coupons(
@router.get("/relevant", response_model=list[CouponResponse]) @router.get("/relevant", response_model=list[CouponResponse])
async def relevant_coupons( async def relevant_coupons(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = CouponService(db) svc = CouponService(db)
+3 -3
View File
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"])
@router.get("/trends", response_model=list[PriceTrendResponse]) @router.get("/trends", response_model=list[PriceTrendResponse])
async def price_trends( async def price_trends(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
category: str | None = Query(None), category: str | None = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -30,7 +30,7 @@ async def price_trends(
@router.get("/increases", response_model=list[PriceIncreaseResponse]) @router.get("/increases", response_model=list[PriceIncreaseResponse])
async def price_increases( async def price_increases(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = PriceService(db) svc = PriceService(db)
@@ -40,7 +40,7 @@ async def price_increases(
@router.get("/comparison", response_model=list[PriceComparisonResponse]) @router.get("/comparison", response_model=list[PriceComparisonResponse])
async def price_comparison( async def price_comparison(
product_ids: Annotated[list[UUID], Query()], 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), db: AsyncSession = Depends(get_db),
): ):
svc = PriceService(db) svc = PriceService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductResponse]) @router.get("", response_model=list[ProductResponse])
async def list_products( async def list_products(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
q: str | None = Query(None), q: str | None = Query(None),
category: str | None = Query(None), category: str | None = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@@ -29,7 +29,7 @@ async def list_products(
@router.get("/{product_id}", response_model=ProductDetailResponse) @router.get("/{product_id}", response_model=ProductDetailResponse)
async def get_product( async def get_product(
product_id: UUID, product_id: UUID,
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = ProductService(db) svc = ProductService(db)
@@ -44,7 +44,7 @@ async def get_product(
@router.get("/{product_id}/prices", response_model=PriceTrendResponse) @router.get("/{product_id}/prices", response_model=PriceTrendResponse)
async def get_product_prices( async def get_product_prices(
product_id: UUID, product_id: UUID,
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = ProductService(db) svc = ProductService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"])
@router.get("", response_model=list[PurchaseResponse]) @router.get("", response_model=list[PurchaseResponse])
async def list_purchases( async def list_purchases(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
store_id: UUID | None = Query(None), store_id: UUID | None = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
@@ -27,7 +27,7 @@ async def list_purchases(
@router.get("/stats", response_model=PurchaseStatsResponse) @router.get("/stats", response_model=PurchaseStatsResponse)
async def purchase_stats( async def purchase_stats(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = PurchaseService(db) svc = PurchaseService(db)
@@ -37,7 +37,7 @@ async def purchase_stats(
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse) @router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
async def get_purchase( async def get_purchase(
purchase_id: UUID, purchase_id: UUID,
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = PurchaseService(db) svc = PurchaseService(db)
+4 -2
View File
@@ -1,5 +1,7 @@
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness).""" """Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError from httpx import HTTPStatusError, RequestError
@@ -11,7 +13,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"])
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse) @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() client = ReceiptWitnessClient()
try: try:
result = await client.trigger_sync(str(user_id), store_slug) 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]) @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() client = ReceiptWitnessClient()
try: try:
return await client.get_sync_status(str(user_id)) return await client.get_sync_status(str(user_id))
+4 -2
View File
@@ -1,5 +1,7 @@
"""Shopping routes: optimize list, saved lists.""" """Shopping routes: optimize list, saved lists."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError from httpx import HTTPStatusError, RequestError
@@ -11,7 +13,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.post("/optimize", response_model=OptimizeResponse) @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() client = ClipArtistClient()
try: try:
result = await client.optimize( 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]) @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() client = ClipArtistClient()
try: try:
return await client.get_shopping_lists(str(user_id)) return await client.get_shopping_lists(str(user_id))
+5 -3
View File
@@ -1,5 +1,7 @@
"""Store routes: list stores, manage user store connections.""" """Store routes: list stores, manage user store connections."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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]) @router.get("/me/stores", response_model=list[StoreAccountResponse])
async def list_user_stores( async def list_user_stores(
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = StoreService(db) svc = StoreService(db)
@@ -34,7 +36,7 @@ async def list_user_stores(
async def connect_store( async def connect_store(
store_slug: str, store_slug: str,
body: ConnectStoreRequest, body: ConnectStoreRequest,
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = StoreService(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) @router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect_store( async def disconnect_store(
store_slug: str, store_slug: str,
user_id: str = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = StoreService(db) svc = StoreService(db)
-1
View File
@@ -1,7 +1,6 @@
"""Pydantic v2 request/response schemas for all API endpoints.""" """Pydantic v2 request/response schemas for all API endpoints."""
from datetime import datetime from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
+5 -3
View File
@@ -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. This service reads them for the API gateway.
""" """
from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -13,7 +15,7 @@ class AlertService:
def __init__(self, db: AsyncSession) -> None: def __init__(self, db: AsyncSession) -> None:
self.db = db 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.""" """List shrinkflation events for products the user has purchased."""
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
@@ -55,7 +57,7 @@ class AlertService:
for e in events 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. # Alert settings would be stored in a user_settings table.
# For now, return defaults since the table doesn't exist yet in common lib. # For now, return defaults since the table doesn't exist yet in common lib.
return { return {
@@ -64,7 +66,7 @@ class AlertService:
"email_notifications": False, "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. # Would update user_settings table. Return merged defaults for now.
current = await self.get_settings(user_id) current = await self.get_settings(user_id)
for k, v in fields.items(): for k, v in fields.items():
+1 -1
View File
@@ -29,7 +29,7 @@ class CouponService:
coupons = result.scalars().all() coupons = result.scalars().all()
return [self._to_dict(c) for c in coupons] 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.""" """Coupons for products the user has purchased."""
from cartsnitch_api.models import Coupon, PurchaseItem from cartsnitch_api.models import Coupon, PurchaseItem
+3 -3
View File
@@ -13,7 +13,7 @@ class PurchaseService:
async def list_purchases( async def list_purchases(
self, self,
user_id: str, user_id: UUID,
store_id: UUID | None = None, store_id: UUID | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
@@ -56,7 +56,7 @@ class PurchaseService:
for p, item_count, store_name in result.all() 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 from cartsnitch_api.models import Purchase
result = await self.db.execute( 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 from cartsnitch_api.models import Purchase
result = await self.db.execute( result = await self.db.execute(
+4 -3
View File
@@ -1,6 +1,7 @@
"""Store service — list stores, manage user store account connections.""" """Store service — list stores, manage user store account connections."""
import json import json
from uuid import UUID
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from sqlalchemy import select from sqlalchemy import select
@@ -34,7 +35,7 @@ class StoreService:
for s in stores 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 from cartsnitch_api.models import UserStoreAccount
result = await self.db.execute( result = await self.db.execute(
@@ -59,7 +60,7 @@ class StoreService:
for a in accounts 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 from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug)) result = await self.db.execute(select(Store).where(Store.slug == store_slug))
@@ -106,7 +107,7 @@ class StoreService:
"sync_status": "active", "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 from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug)) result = await self.db.execute(select(Store).where(Store.slug == store_slug))
@@ -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
+3 -2
View File
@@ -6,13 +6,14 @@ from httpx import ASGITransport, AsyncClient
from cartsnitch_api.main import app from cartsnitch_api.main import app
EXPECTED_ROUTES = [ EXPECTED_ROUTES = [
# Auth (6) # Auth (7)
("post", "/auth/register"), ("post", "/auth/register"),
("post", "/auth/login"), ("post", "/auth/login"),
("post", "/auth/refresh"), ("post", "/auth/refresh"),
("get", "/auth/me"), ("get", "/auth/me"),
("patch", "/auth/me"), ("patch", "/auth/me"),
("delete", "/auth/me"), ("delete", "/auth/me"),
("get", "/auth/me/email-in-address"),
# Stores (4) # Stores (4)
("get", "/stores"), ("get", "/stores"),
("get", "/me/stores"), ("get", "/me/stores"),
@@ -89,4 +90,4 @@ async def test_route_count():
if method in ("get", "post", "put", "delete", "patch"): if method in ("get", "post", "put", "delete", "patch"):
count += 1 count += 1
assert count == 33, f"Expected 33 routes, found {count}" assert count == 34, f"Expected 34 routes, found {count}"
+61
View File
@@ -0,0 +1,61 @@
# seed-dev-job.yaml
# K8s Job to run the CartSnitch seed runner against the dev database.
#
# Usage:
# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
#
# To view logs:
# kubectl logs -n cartsnitch-dev job/seed-dev -f
#
# To re-run after fixing issues:
# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
#
apiVersion: batch/v1
kind: Job
metadata:
name: seed-dev
namespace: cartsnitch-dev
labels:
app: cartsnitch
component: seed
environment: dev
annotations:
description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data."
spec:
# Prevent retries — a failed seed run should be investigated, not auto-repeated.
backoffLimit: 0
# Do not run concurrently; sequential runs are safer for truncate+reseed.
concurrencyPolicy: Forbid
template:
metadata:
labels:
app: cartsnitch
component: seed
environment: dev
spec:
restartPolicy: Never
containers:
- name: seed
# Use slim Python image with the cartsnitch-common package installed from git.
# The common repo is public; no additional secret is needed for the pip install.
image: python:3.12-slim
command:
- sh
- -c
- |
pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \
python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: cartsnitch-secrets
key: database-url-pg
optional: false
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# =============================================================================
# seed-dev.sh — Run the CartSnitch seed runner against the dev database.
#
# Usage:
# ./seed-dev.sh Run full seed against dev
# ./seed-dev.sh --dry-run Show planned record counts without writing
# ./seed-dev.sh --help Show this help
#
# Prerequisites:
# - kubectl configured for the cartsnitch-dev cluster
# - Namespace cartsnitch-dev exists (CNPG Postgres must be running)
#
# What it does:
# 1. Starts a background port-forward to cartsnitch-pg-rw:5432
# 2. Waits for the tunnel to be ready
# 3. Runs python -m cartsnitch_common.seed with --database-url pointing
# to localhost:<forwarded-port>/cartsnitch
# 4. Cleans up the port-forward on exit (normal, interrupt, or error)
# =============================================================================
set -euo pipefail
# --- Config -------------------------------------------------------------------
readonly NAMESPACE="cartsnitch-dev"
readonly SVC_NAME="cartsnitch-pg-rw"
readonly LOCAL_PORT="5433" # use a non-privileged port to avoid conflicts
readonly DB_NAME="cartsnitch"
readonly PG_USER="cartsnitch"
# Retrieve password from the CNPG credentials secret
readonly PG_PASSWORD="$(
kubectl get secret cartsnitch-pg-credentials \
-n "$NAMESPACE" \
-o jsonpath='{.data.password}' \
| base64 -d
)"
readonly DB_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${LOCAL_PORT}/${DB_NAME}"
# --- Helpers ------------------------------------------------------------------
log() { echo "[seed-dev] $*"; }
fail() { log "ERROR: $*" >&2; exit 1; }
# Cleanup port-forward and exit.
cleanup() {
if [[ -n "${PF_PID:-}" ]]; then
log "Stopping port-forward (PID $PF_PID)..."
kill "$PF_PID" 2>/dev/null || true
wait "$PF_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# --- Args ---------------------------------------------------------------------
DRY_RUN=""
HELP_FLAG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN="--dry-run"; shift ;;
--help) HELP_FLAG="1"; shift ;;
*) fail "Unknown argument: $1";;
esac
done
if [[ -n "$HELP_FLAG" ]]; then
sed -n '3,/^# ---/p' "$0" | head -n -1 | sed 's/^# //'
echo ""
echo "Additional arguments are passed through to the seed runner."
echo "Common seed-runner options:"
echo " --dry-run Show planned record counts without writing"
echo " --seed N Set random seed (default: 42)"
exit 0
fi
# --- Prerequisites ------------------------------------------------------------
if ! command -v kubectl &>/dev/null; then
fail "kubectl not found — must be installed and configured."
fi
# --- Port-forward -------------------------------------------------------------
log "Starting port-forward ${SVC_NAME}:5432 -> localhost:${LOCAL_PORT} ..."
kubectl port-forward \
-n "$NAMESPACE" \
svc/"$SVC_NAME" \
"${LOCAL_PORT}:5432" \
&>/dev/null &
PF_PID=$!
# Give the tunnel a moment to establish
sleep 2
# Verify the tunnel is up
if ! kill -0 "$PF_PID" 2>/dev/null; then
fail "Port-forward failed to start."
fi
log "Port-forward active (PID $PF_PID) on localhost:${LOCAL_PORT}"
# --- Seed --------------------------------------------------------------------
log "Running seed against dev database..."
set -x
python -m cartsnitch_common.seed --database-url "$DB_URL" $DRY_RUN
set +x
log "Done."
+3 -31
View File
@@ -1,13 +1,8 @@
import React, { Suspense } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts' import { authClient } from '../lib/auth-client.ts'
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts' import { usePurchases, usePriceAlerts } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx' import { StoreIcon } from '../components/StoreIcon.tsx'
const LazySparklineCard = React.lazy(() =>
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
)
export function Dashboard() { export function Dashboard() {
const { data: session, isPending } = authClient.useSession() const { data: session, isPending } = authClient.useSession()
@@ -44,19 +39,11 @@ export function Dashboard() {
function AuthenticatedDashboard({ userName }: { userName: string }) { function AuthenticatedDashboard({ userName }: { userName: string }) {
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases() const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts() const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
const { data: eggHistory = [] } = usePriceHistory('prod10')
const { data: milkHistory = [] } = usePriceHistory('prod1')
const triggeredAlerts = alerts.filter((a) => a.triggered) const triggeredAlerts = alerts.filter((a) => a.triggered)
const watchingAlerts = alerts.filter((a) => !a.triggered) const watchingAlerts = alerts.filter((a) => !a.triggered)
const recentPurchases = purchases.slice(0, 3) const recentPurchases = purchases.slice(0, 3)
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
if (purchasesLoading || alertsLoading) { if (purchasesLoading || alertsLoading) {
return <DashboardSkeleton /> return <DashboardSkeleton />
} }
@@ -106,11 +93,8 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
{/* Price trend sparklines */} {/* Price trend sparklines */}
<section className="mt-6"> <section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2> <h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
<div className="space-y-3"> <div className="rounded-xl bg-white p-4 shadow-sm text-center text-sm text-gray-400">
<Suspense fallback={<SparklinePlaceholder />}> Connect a store to see price trends
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
</Suspense>
</div> </div>
</section> </section>
@@ -187,15 +171,3 @@ function DashboardSkeleton() {
</div> </div>
) )
} }
function SparklinePlaceholder() {
return (
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
<div className="min-w-0 flex-1">
<div className="h-4 w-24 rounded bg-gray-200" />
<div className="mt-2 h-6 w-16 rounded bg-gray-200" />
</div>
<div className="h-10 w-24 rounded bg-gray-100" />
</div>
)
}