Compare commits

...

19 Commits

Author SHA1 Message Date
cartsnitch-engineer[bot] f7e1574176 fix(api): replace UUID type with str for Better-Auth nanoid user IDs (#98)
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: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 19:15:58 +00:00
cartsnitch-ceo[bot] ee6352a2f5 fix(api): parse signed session cookie instead of SHA-256 hashing
fix(api): parse signed session cookie instead of SHA-256 hashing
2026-04-01 11:34:59 +00:00
CartSnitch Engineer Bot 2f37f0501f fix(api): parse signed session cookie instead of SHA-256 hashing
Better-Auth v1.5.6 stores raw tokens in sessions.token, not SHA-256
hashes. The session cookie is signed (rawToken.hmacSignature), so
strip the HMAC signature suffix before querying the DB.

Fixes 401 errors on all data endpoints caused by the incorrect hash.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 11:09:29 +00:00
cartsnitch-cto[bot] 4c36fd4156 fix(api): restore SHA-256 session token hashing (regression from PR #95)
Restores sha256 import and token hashing in _validate_session_token.

Regression introduced when PR #95 (cookie name fix) was merged without
the hash fix from PR #93.

QA approved: CAR-324 (Checkout Charlie)
CTO approved: Paperclip (Savannah Savings)
Resolves CAR-323

cc @cpfarhood
2026-04-01 10:29:05 +00:00
cartsnitch-ceo[bot] c9172f088f fix(api): read __Secure- prefixed session cookie for HTTPS environments
Merges fix/secure-cookie-name. Resolves CAR-321.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 08:16:41 +00:00
cartsnitch-engineer[bot] ac4cba2b0d fix(api): read __Secure- prefixed session cookie for HTTPS environments
Better-Auth automatically prefixes cookie names with __Secure- when serving
over HTTPS. The API gateway now tries __Secure-better-auth.session_token
first (HTTPS/deployed), falling back to better-auth.session_token (HTTP/local dev).

Fixes CAR-321.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 04:02:49 +00:00
cartsnitch-cto[bot] 0c47be8ef3 fix(frontend): align API route paths with backend (alerts, price-history)
CEO merge: QA approved (cartsnitch-qa[bot]), CTO approved (cartsnitch-cto[bot]), CI green. Merging per SDLC gatekeeper role.
2026-04-01 03:13:01 +00:00
cartsnitch-cto[bot] 440f92e96e Merge branch 'main' into fix/frontend-api-routes 2026-04-01 03:08:44 +00:00
cartsnitch-ceo[bot] 97bbdf68a5 fix(api): hash session token before DB lookup to match Better-Auth storage
fix(api): hash session token before DB lookup to match Better-Auth storage
2026-04-01 02:49:07 +00:00
CartSnitch Engineer Bot 02e5bee390 fix(frontend): align API route paths with backend (alerts, price-history)
Change frontend to call /alerts (was /price-alerts) and /products/{id}/prices
(was /products/{id}/price-history) to match the backend router mounts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 02:10:12 +00:00
CartSnitch Engineer Bot d475b3876a fix(api): hash session token before DB lookup to match Better-Auth storage
Better-Auth v1.5.6+ stores session tokens as SHA-256 hashes in the
sessions table. The raw token from the cookie was being queried directly,
causing all authenticated /api/v1/* requests to return 401.

Fixes CAR-313.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 02:09:55 +00:00
cartsnitch-qa[bot] 76bcc53992 fix(api): mount data routers under /api/v1 prefix
Merges fix for CAR-310 / CAR-161 UAT failure. QA approved, CTO approved, CI green.
2026-04-01 01:50:20 +00:00
cartsnitch-qa[bot] 470b615528 Merge branch 'main' into fix/api-v1-prefix 2026-04-01 01:45:37 +00:00
CartSnitch Engineer Bot f26f8f7e56 fix(api): mount data routers under /api/v1 prefix
Fixes CAR-161 UAT failure: k8s HTTPRoute forwards /api/* to the API
gateway without path rewriting, so requests arrive at FastAPI as
/api/v1/purchases, /api/v1/products, etc. FastAPI previously mounted
data routers at root, causing 404s on all /api/v1/* calls.

Keep health and auth routers at root (probes hit /health directly;
auth traffic is routed to the auth service via HTTPRoute).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 23:56:20 +00:00
cartsnitch-ceo[bot] 78b7831d43 Merge pull request #91 from cartsnitch/fix/registration-redirect
fix(auth): wait for session confirmation before post-auth redirect
2026-03-31 23:14:04 +00:00
CartSnitch Engineer Bot e45b510519 Merge commit '8af7b37b38f3d5c5cb13b3e98530ec4d6127b755' into fix/registration-redirect 2026-03-31 23:08:22 +00:00
CartSnitch Engineer Bot f25044ea7e fix(auth): restore setAuthenticated in mock-auth catch block
The try-block getSession() pattern is correct for real auth mode.
The mock-auth catch block (VITE_MOCK_AUTH) still needs to set
the Zustand flag so ProtectedRoute respects the authenticated state.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 22:30:05 +00:00
CartSnitch Engineer Bot b637fd9c11 fix(auth): wait for session confirmation before post-auth redirect
Race condition between signUp/signIn completion and ProtectedRoute's
useSession() call caused redirect loops — Better-Auth's session cookie
is not immediately visible to useSession() after signUp/signIn resolves.

Fix: call authClient.getSession() explicitly after signUp/signIn to
synchronize before navigating to protected routes. Fall back to error
message if session not confirmed.

Also removes dead setAuthenticated() calls that only work in mock mode.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 22:11:20 +00:00
cartsnitch-engineer[bot] 8af7b37b38 fix(api): run Alembic migrations on startup (#90)
Merged by Coupon Carl (CEO). QA approved, CTO approved. CI green (lighthouse failure is known/tracked). cc @cpfarhood
2026-03-31 21:55:00 +00:00
23 changed files with 100 additions and 87 deletions
+1 -1
View File
@@ -30,4 +30,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
+22 -10
View File
@@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -19,18 +18,27 @@ 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 name
SESSION_COOKIE_NAME = "better-auth.session_token"
# 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)
]
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
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 UUID) 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(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
{"token": raw_token},
)
row = result.first()
@@ -51,14 +59,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
detail="Session expired",
)
return UUID(str(user_id))
return str(user_id)
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> UUID:
) -> str:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
@@ -67,8 +75,12 @@ async def get_current_user(
"""
token: str | None = None
# 1. Check session cookie
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
# 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
if cookie_token:
token = cookie_token
+4 -5
View File
@@ -2,22 +2,21 @@
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: UUID) -> str:
def create_access_token(user_id: str) -> str:
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
payload = {"sub": 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: UUID) -> str:
def create_refresh_token(user_id: str) -> str:
expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
payload = {"sub": user_id, "exp": expire, "type": "refresh"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
+3 -5
View File
@@ -5,8 +5,6 @@ the Better-Auth service (auth/). This router provides user profile
endpoints that query our own user data from the shared database.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,7 +21,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -38,7 +36,7 @@ async def get_me(
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -54,7 +52,7 @@ async def update_me(
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
+14 -10
View File
@@ -2,7 +2,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.middleware.cors import add_cors_middleware
@@ -46,15 +46,19 @@ def create_app() -> FastAPI:
# Routers
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(stores_router)
app.include_router(purchases_router)
app.include_router(products_router)
app.include_router(prices_router)
app.include_router(coupons_router)
app.include_router(shopping_router)
app.include_router(alerts_router)
app.include_router(scraping_router)
app.include_router(public_router)
# Data endpoints mounted under /api/v1
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(stores_router)
v1_router.include_router(purchases_router)
v1_router.include_router(products_router)
v1_router.include_router(prices_router)
v1_router.include_router(coupons_router)
v1_router.include_router(shopping_router)
v1_router.include_router(alerts_router)
v1_router.include_router(scraping_router)
v1_router.include_router(public_router)
app.include_router(v1_router)
return app
+3 -5
View File
@@ -1,7 +1,5 @@
"""Alert routes: list alerts, manage settings."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,7 +13,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"])
@router.get("", response_model=list[AlertResponse])
async def list_alerts(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
@@ -24,7 +22,7 @@ async def list_alerts(
@router.get("/settings", response_model=AlertSettingsResponse)
async def get_alert_settings(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
@@ -34,7 +32,7 @@ async def get_alert_settings(
@router.put("/settings")
async def update_alert_settings(
body: AlertSettingsRequest,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(
+2 -2
View File
@@ -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: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_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])
async def price_trends(
user_id: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductResponse])
async def list_products(
user_id: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"])
@router.get("", response_model=list[PurchaseResponse])
async def list_purchases(
user_id: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = 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: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
+2 -4
View File
@@ -1,7 +1,5 @@
"""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
@@ -13,7 +11,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"])
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)):
async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
result = await client.trigger_sync(str(user_id), store_slug)
@@ -31,7 +29,7 @@ async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user
@router.get("/status", response_model=list[SyncStatusResponse])
async def sync_status(user_id: UUID = Depends(get_current_user)):
async def sync_status(user_id: str = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
return await client.get_sync_status(str(user_id))
+2 -4
View File
@@ -1,7 +1,5 @@
"""Shopping routes: optimize list, saved lists."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
@@ -13,7 +11,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.post("/optimize", response_model=OptimizeResponse)
async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)):
async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)):
client = ClipArtistClient()
try:
result = await client.optimize(
@@ -37,7 +35,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_c
@router.get("/lists", response_model=list[ShoppingListResponse])
async def list_shopping_lists(user_id: UUID = Depends(get_current_user)):
async def list_shopping_lists(user_id: str = Depends(get_current_user)):
client = ClipArtistClient()
try:
return await client.get_shopping_lists(str(user_id))
+3 -5
View File
@@ -1,7 +1,5 @@
"""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
@@ -21,7 +19,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: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
@@ -36,7 +34,7 @@ async def list_user_stores(
async def connect_store(
store_slug: str,
body: ConnectStoreRequest,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
@@ -51,7 +49,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: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
+3 -5
View File
@@ -4,8 +4,6 @@ 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
@@ -15,7 +13,7 @@ class AlertService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_alerts(self, user_id: UUID) -> list[dict]:
async def list_alerts(self, user_id: str) -> list[dict]:
"""List shrinkflation events for products the user has purchased."""
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
@@ -57,7 +55,7 @@ class AlertService:
for e in events
]
async def get_settings(self, user_id: UUID) -> dict:
async def get_settings(self, user_id: str) -> 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 {
@@ -66,7 +64,7 @@ class AlertService:
"email_notifications": False,
}
async def update_settings(self, user_id: UUID, **fields) -> dict:
async def update_settings(self, user_id: str, **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():
+3 -5
View File
@@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides
user lookup and profile update operations for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,7 +13,7 @@ class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_user(self, user_id: UUID) -> dict:
async def get_user(self, user_id: str) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -30,7 +28,7 @@ class AuthService:
"created_at": user.created_at,
}
async def update_user(self, user_id: UUID, **fields) -> dict:
async def update_user(self, user_id: str, **fields) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -58,7 +56,7 @@ class AuthService:
"created_at": user.created_at,
}
async def delete_user(self, user_id: UUID) -> None:
async def delete_user(self, user_id: str) -> None:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
+1 -1
View File
@@ -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: UUID) -> list[dict]:
async def relevant_coupons(self, user_id: str) -> list[dict]:
"""Coupons for products the user has purchased."""
from cartsnitch_api.models import Coupon, PurchaseItem
+3 -3
View File
@@ -13,7 +13,7 @@ class PurchaseService:
async def list_purchases(
self,
user_id: UUID,
user_id: str,
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: UUID) -> dict:
async def get_purchase(self, purchase_id: UUID, user_id: str) -> 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: UUID) -> dict:
async def get_stats(self, user_id: str) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
+3 -4
View File
@@ -1,7 +1,6 @@
"""Store service — list stores, manage user store account connections."""
import json
from uuid import UUID
from cryptography.fernet import Fernet
from sqlalchemy import select
@@ -35,7 +34,7 @@ class StoreService:
for s in stores
]
async def list_user_stores(self, user_id: UUID) -> list[dict]:
async def list_user_stores(self, user_id: str) -> list[dict]:
from cartsnitch_api.models import UserStoreAccount
result = await self.db.execute(
@@ -60,7 +59,7 @@ class StoreService:
for a in accounts
]
async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict:
async def connect_store(self, user_id: str, 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))
@@ -107,7 +106,7 @@ class StoreService:
"sync_status": "active",
}
async def disconnect_store(self, user_id: UUID, store_slug: str) -> None:
async def disconnect_store(self, user_id: str, store_slug: str) -> None:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
+2 -2
View File
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
export function usePriceHistory(productId: string) {
return useQuery({
queryKey: ['priceHistory', productId],
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
enabled: !!productId,
})
}
@@ -50,6 +50,6 @@ export function useCoupons() {
export function usePriceAlerts() {
return useQuery({
queryKey: ['priceAlerts'],
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
queryFn: () => api.get<PriceAlert[]>('/alerts'),
})
}
+2 -2
View File
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
'/alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
+8 -2
View File
@@ -31,8 +31,14 @@ export function Login() {
throw new Error(authError.message ?? 'Sign in failed')
}
setAuthenticated(true)
navigate('/')
// After successful signIn, force a session fetch to confirm the cookie is set
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
setError('Sign in failed. Please try again.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
+9 -2
View File
@@ -38,8 +38,15 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed')
}
setAuthenticated(true)
navigate('/')
// After successful signUp, force a session fetch to confirm the cookie is set
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
// Session not established — show success message and link to login
setError('Account created! Please sign in.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
+1 -1
View File
@@ -61,5 +61,5 @@ export const handlers = [
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
]