Compare commits

..

10 Commits

Author SHA1 Message Date
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
8 changed files with 180 additions and 43 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, 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"
+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 datetime from datetime import date, 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: datetime purchased_at: date
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: datetime | None = None expires_at: date | None = None
# ---------- Shopping ---------- # ---------- Shopping ----------
+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>
)
}