Compare commits

..

2 Commits

Author SHA1 Message Date
CartSnitch Engineer Bot b67c257b7c feat(scripts): add dev environment seed script and K8s Job
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:26:35 +00:00
CartSnitch Engineer Bot 51e6d2493c 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>
2026-04-01 13:28:45 +00:00
6 changed files with 43 additions and 15 deletions
+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, UUIDPrimaryKeyMixin from cartsnitch_api.models.base import Base, TimestampMixin, 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, Base): class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, 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, UUIDPrimaryKeyMixin from cartsnitch_api.models.base import Base, TimestampMixin, 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, Base): class PriceHistory(UUIDPrimaryKeyMixin, TimestampMixin, 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, UUIDPrimaryKeyMixin from cartsnitch_api.models.base import Base, TimestampMixin, 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, Base): class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A single shopping trip / receipt.""" """A single shopping trip / receipt."""
__tablename__ = "purchases" __tablename__ = "purchases"
@@ -61,7 +61,7 @@ class Purchase(UUIDPrimaryKeyMixin, Base):
) )
class PurchaseItem(UUIDPrimaryKeyMixin, Base): class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, 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, UUIDPrimaryKeyMixin from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct from cartsnitch_api.models.product import NormalizedProduct
class ShrinkflationEvent(UUIDPrimaryKeyMixin, Base): class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Detected shrinkflation event — product size changed while price held or rose.""" """Detected shrinkflation event — product size changed while price held or rose."""
__tablename__ = "shrinkflation_events" __tablename__ = "shrinkflation_events"
+3 -3
View File
@@ -1,6 +1,6 @@
"""Pydantic v2 request/response schemas for all API endpoints.""" """Pydantic v2 request/response schemas for all API endpoints."""
from datetime import date, datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@@ -60,7 +60,7 @@ class PurchaseResponse(BaseModel):
id: UUID id: UUID
store_id: UUID store_id: UUID
store_name: str store_name: str
purchased_at: date purchased_at: datetime
total: float total: float
item_count: int item_count: int
@@ -142,7 +142,7 @@ class CouponResponse(BaseModel):
discount_value: float discount_value: float
discount_type: str discount_type: str
product_id: UUID | None = None product_id: UUID | None = None
expires_at: date | None = None expires_at: datetime | None = None
# ---------- Shopping ---------- # ---------- Shopping ----------
+31 -3
View File
@@ -1,8 +1,13 @@
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 } from '../hooks/useApi.ts' import { usePurchases, usePriceAlerts, usePriceHistory } 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()
@@ -39,11 +44,19 @@ 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 />
} }
@@ -93,8 +106,11 @@ 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="rounded-xl bg-white p-4 shadow-sm text-center text-sm text-gray-400"> <div className="space-y-3">
Connect a store to see price trends <Suspense fallback={<SparklinePlaceholder />}>
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
</Suspense>
</div> </div>
</section> </section>
@@ -171,3 +187,15 @@ 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>
)
}