Compare commits

...

8 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
8 changed files with 26 additions and 18 deletions
+12 -4
View File
@@ -20,8 +20,12 @@ 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 name # Better-Auth session cookie names.
SESSION_COOKIE_NAME = "better-auth.session_token" # 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) -> UUID:
@@ -71,8 +75,12 @@ async def get_current_user(
""" """
token: str | None = None token: str | None = None
# 1. Check session cookie # 1. Check session cookie (try both names for HTTP/HTTPS compatibility)
cookie_token = request.cookies.get(SESSION_COOKIE_NAME) cookie_token = None
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
+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 sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import DiscountType 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: if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.store import Store 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.""" """A coupon or deal for a product at a store."""
__tablename__ = "coupons" __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 sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import PriceSource 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: if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct from cartsnitch_api.models.product import NormalizedProduct
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store 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.""" """A single price observation for a product at a store on a date."""
__tablename__ = "price_history" __tablename__ = "price_history"
+3 -3
View File
@@ -18,7 +18,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship 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: if TYPE_CHECKING:
from cartsnitch_api.models.price import PriceHistory from cartsnitch_api.models.price import PriceHistory
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
from cartsnitch_api.models.user import User from cartsnitch_api.models.user import User
class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): class Purchase(UUIDPrimaryKeyMixin, Base):
"""A single shopping trip / receipt.""" """A single shopping trip / receipt."""
__tablename__ = "purchases" __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.""" """Individual line item on a receipt."""
__tablename__ = "purchase_items" __tablename__ = "purchase_items"
@@ -9,13 +9,13 @@ from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import SizeUnit 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: if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct 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.""" """Detected shrinkflation event — product size changed while price held or rose."""
__tablename__ = "shrinkflation_events" __tablename__ = "shrinkflation_events"
+2 -2
View File
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
export function usePriceHistory(productId: string) { export function usePriceHistory(productId: string) {
return useQuery({ return useQuery({
queryKey: ['priceHistory', productId], queryKey: ['priceHistory', productId],
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`), queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
enabled: !!productId, enabled: !!productId,
}) })
} }
@@ -50,6 +50,6 @@ export function useCoupons() {
export function usePriceAlerts() { export function usePriceAlerts() {
return useQuery({ return useQuery({
queryKey: ['priceAlerts'], 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, '/purchases': () => mockPurchases,
'/products': () => mockProducts, '/products': () => mockProducts,
'/coupons': () => mockCoupons, '/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts, '/alerts': () => mockAlerts,
} }
function matchMockRoute<T>(path: string): T | null { function matchMockRoute<T>(path: string): T | null {
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
} }
// /products/:id/price-history // /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/) const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
if (priceHistoryMatch) { if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T 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', () => HttpResponse.json(mockProducts)),
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])), http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)), 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)),
] ]