Compare commits

..

12 Commits

Author SHA1 Message Date
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
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
9 changed files with 45 additions and 29 deletions
+17 -5
View File
@@ -5,6 +5,7 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from hashlib import sha256
from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
@@ -19,18 +20,25 @@ 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:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as UUID) if the session is valid and not expired.
Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the
incoming raw token before querying.
"""
hashed_token = sha256(token.encode("utf-8")).hexdigest()
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
{"token": hashed_token},
)
row = result.first()
@@ -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
+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
+2 -2
View File
@@ -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, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.store import Store
class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class Coupon(UUIDPrimaryKeyMixin, Base):
"""A coupon or deal for a product at a store."""
__tablename__ = "coupons"
+2 -2
View File
@@ -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, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, 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, TimestampMixin, Base):
class PriceHistory(UUIDPrimaryKeyMixin, Base):
"""A single price observation for a product at a store on a date."""
__tablename__ = "price_history"
+3 -3
View File
@@ -18,7 +18,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.price import PriceHistory
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
from cartsnitch_api.models.user import User
class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class Purchase(UUIDPrimaryKeyMixin, Base):
"""A single shopping trip / receipt."""
__tablename__ = "purchases"
@@ -61,7 +61,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
)
class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class PurchaseItem(UUIDPrimaryKeyMixin, Base):
"""Individual line item on a receipt."""
__tablename__ = "purchase_items"
@@ -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, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class ShrinkflationEvent(UUIDPrimaryKeyMixin, Base):
"""Detected shrinkflation event — product size changed while price held or rose."""
__tablename__ = "shrinkflation_events"
+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
}
+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)),
]