Compare commits

..

8 Commits

Author SHA1 Message Date
cartsnitch-engineer[bot] 4415c56a53 Add CartSnitch vs Flipp SEO comparison article
SEO comparison article targeting CartSnitch vs Flipp queries. Math verified, no fabricated citations, feature statuses accurate. CTO + CEO approved.
2026-03-28 03:30:26 +00:00
cartsnitch-ceo[bot] dd6a683b90 Merge PR #38: Add unit price explainer article
Add unit price explainer article for SEO
2026-03-28 03:27:45 +00:00
cartsnitch-ceo[bot] cf8e821bdc ci: proper Docker GHA cache + remove Docker Hub login (CAR-272, CAR-273)
ci: proper Docker GHA cache + remove Docker Hub login (CAR-272, CAR-273)
2026-03-28 03:24:24 +00:00
Chris Farhood c9be9324cf Merge pull request #48 from cartsnitch/feature/repo-consolidation
feat: consolidate api, common, receiptwitness into monorepo
2026-03-27 22:54:34 -04:00
Deploy Debbie fc99e8a82e ci: replace no-cache with GHA cache + remove Docker Hub login (CAR-272, CAR-273)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 07:14:05 +00:00
cartsnitch-engineer[bot] cb1d926fc4 fix: add no-cache to docker build-push-action to prevent stale nginx config cache (CAR-265)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 06:48:36 +00:00
cartsnitch-engineer[bot] e662ff5fab Fix unit price percentage: 16.2% → 16.1% (and trailing '16%' → '16.1%')
(P/15.5) / (P/18) - 1 = 18/15.5 - 1 = 16.1%, not 16.2%. 
Addresses CTO review request on PR #38.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-22 07:57:12 +00:00
Frontend Frankie 853d722044 Add unit price explainer article for SEO
Adds top-of-funnel explainer article targeting "what is unit price",
"how to calculate unit price", and "unit price vs shelf price" keywords.
Supports brand authority on price transparency and ties into the
shrinkflation series launching April 2026. Closes CAR-218.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-22 03:51:35 +00:00
32 changed files with 1138 additions and 1350 deletions
+2 -92
View File
@@ -17,7 +17,6 @@ permissions:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/cartsnitch IMAGE_NAME: cartsnitch/cartsnitch
AUTH_IMAGE_NAME: cartsnitch/auth
jobs: jobs:
lint: lint:
@@ -71,12 +70,6 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION" echo "CalVer tag: $VERSION"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -103,94 +96,11 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
target: prod target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create git tag - name: Create git tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: | run: |
git tag "v${{ steps.calver.outputs.version }}" git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}" git push origin "v${{ steps.calver.outputs.version }}"
build-and-push-auth:
runs-on: runners-cartsnitch
needs: [lint, test]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then
VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
VERSION="${DATE_TAG}.2"
else
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (auth)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push auth Docker image
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
path: infra
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update dev overlay image tag
run: |
cd infra
kustomize edit set image \
ghcr.io/cartsnitch/cartsnitch=cartsnitch/cartsnitch:${{ github.sha }} \
ghcr.io/cartsnitch/auth=cartsnitch/auth:${{ github.sha }}
- name: Commit and push
run: |
cd infra
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git diff --staged --quiet || git commit -m "chore: update dev image tags to ${{ github.sha }}"
git push
-1
View File
@@ -11,7 +11,6 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+2 -7
View File
@@ -12,7 +12,6 @@ CartSnitch is a self-hosted grocery price intelligence platform. This repo (`car
| Directory | Service | Purpose | | Directory | Service | Purpose |
|-----------|---------|---------| |-----------|---------|---------|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) | | `/` (root) | Frontend | React PWA, mobile-first (this directory) |
| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
| `api/` | API Gateway | Frontend-facing REST API | | `api/` | API Gateway | Frontend-facing REST API |
| `common/` | Common | Shared Python models, schemas, Alembic migrations | | `common/` | Common | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers | | `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
@@ -167,13 +166,9 @@ frontend/
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`. All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
- **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory. - JWT auth: store access token in memory (not localStorage), refresh token in httpOnly cookie if possible, or secure storage.
- Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
- Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
- API gateway validates sessions by querying the shared `sessions` table in Postgres
- Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
- TanStack Query handles caching, background refetching, and optimistic updates. - TanStack Query handles caching, background refetching, and optimistic updates.
- API client sends `credentials: 'include'` on all requests to forward session cookies. - API client should handle 401 responses by attempting token refresh before retrying.
## Development Workflow ## Development Workflow
@@ -1,101 +0,0 @@
"""Add Better-Auth tables and extend users table.
Creates sessions, accounts, and verifications tables for Better-Auth.
Adds email_verified and image columns to existing users table.
Migrates password hashes from users.hashed_password to accounts.password.
Revision ID: 002_better_auth_tables
Revises: 001_encrypt_session_data
Create Date: 2026-03-28
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "002_better_auth_tables"
down_revision = "001_encrypt_session_data"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- Extend users table for Better-Auth compatibility ---
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
# --- Create sessions table ---
op.create_table(
"sessions",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
# --- Create accounts table ---
op.create_table(
"accounts",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("account_id", sa.Text(), nullable=False),
sa.Column("provider_id", sa.Text(), nullable=False),
sa.Column("access_token", sa.Text(), nullable=True),
sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("id_token", sa.Text(), nullable=True),
sa.Column("password", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
# --- Create verifications table ---
op.create_table(
"verifications",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("identifier", sa.Text(), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# --- Migrate existing password hashes to accounts table ---
# For each user with a hashed_password, create a 'credential' account row
conn = op.get_bind()
users = conn.execute(
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
).fetchall()
for user_id, hashed_password in users:
user_id_str = str(user_id)
conn.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
),
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
)
def downgrade() -> None:
op.drop_table("verifications")
op.drop_table("accounts")
op.drop_index("ix_sessions_user_id", table_name="sessions")
op.drop_index("ix_sessions_token", table_name="sessions")
op.drop_table("sessions")
op.drop_column("users", "image")
op.drop_column("users", "email_verified")
+17 -71
View File
@@ -1,88 +1,34 @@
"""FastAPI dependency injection for authentication. """FastAPI dependency injection for authentication."""
Validates Better-Auth session tokens from cookies or Bearer header.
Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.jwt import decode_token
from cartsnitch_api.config import settings from cartsnitch_api.config import settings
from cartsnitch_api.database import get_db
# Keep Bearer scheme as optional — Better-Auth primarily uses cookies, bearer_scheme = HTTPBearer()
# 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"
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.
"""
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
)
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session token",
)
user_id, expires_at = row
if expires_at.tzinfo is None:
# Treat naive datetimes as UTC
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired",
)
return UUID(str(user_id))
async def get_current_user( async def get_current_user(
request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> UUID: ) -> UUID:
"""Extract and validate the session token from cookie or Authorization header. try:
payload = decode_token(credentials.credentials)
Checks in order: except ValueError:
1. Better-Auth session cookie (primary — web clients)
2. Bearer token in Authorization header (fallback — API clients)
"""
token: str | None = None
# 1. Check session cookie
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
if cookie_token:
token = cookie_token
# 2. Fall back to Bearer header
if not token and credentials:
token = credentials.credentials
if not token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required", detail="Invalid or expired token",
) ) from None
return await _validate_session_token(token, db) if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
) from None
return UUID(payload["sub"])
async def verify_service_key(x_service_key: str = Header()) -> None: async def verify_service_key(x_service_key: str = Header()) -> None:
+36 -6
View File
@@ -1,9 +1,4 @@
"""Auth routes: user profile management. """Auth routes: register, login, refresh, me, update, delete."""
Registration, login, refresh, and session management are handled by
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 uuid import UUID
@@ -13,6 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import ( from cartsnitch_api.schemas import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UpdateUserRequest, UpdateUserRequest,
UserResponse, UserResponse,
) )
@@ -21,6 +20,37 @@ from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.register(body.email, body.password, body.display_name)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.login(body.email, body.password)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
) from None
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.refresh(body.refresh_token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
) from None
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_me( async def get_me(
user_id: UUID = Depends(get_current_user), user_id: UUID = Depends(get_current_user),
-2
View File
@@ -19,8 +19,6 @@ class Settings(BaseSettings):
# Valid Fernet key for local dev — MUST be overridden in production # Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=" fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
auth_service_url: str = "http://auth:3001"
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"] cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
receiptwitness_url: str = "http://receiptwitness:8001" receiptwitness_url: str = "http://receiptwitness:8001"
+22 -2
View File
@@ -6,8 +6,28 @@ from uuid import UUID
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
# ---------- Auth ---------- # ---------- Auth ----------
# Registration, login, and session management are handled by Better-Auth (auth/ service).
# These schemas are for the profile management endpoints only.
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class UpdateUserRequest(BaseModel): class UpdateUserRequest(BaseModel):
+61 -6
View File
@@ -1,20 +1,67 @@
"""Auth service — user profile management. """Auth service — user registration, login, token management."""
Registration, login, token management, and session handling are now
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 uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.jwt import create_access_token, create_refresh_token, decode_token
from cartsnitch_api.auth.passwords import hash_password, verify_password
from cartsnitch_api.config import settings
class AuthService: class AuthService:
def __init__(self, db: AsyncSession) -> None: def __init__(self, db: AsyncSession) -> None:
self.db = db self.db = db
async def register(self, email: str, password: str, display_name: str) -> dict:
from cartsnitch_api.models import User
existing = await self.db.execute(select(User).where(User.email == email))
if existing.scalar_one_or_none():
raise ValueError("Email already registered")
user = User(
email=email,
hashed_password=hash_password(password),
display_name=display_name,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return self._make_token_response(user.id)
async def login(self, email: str, password: str) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.hashed_password):
raise ValueError("Invalid email or password")
return self._make_token_response(user.id)
async def refresh(self, refresh_token: str) -> dict:
from cartsnitch_api.models import User
try:
payload = decode_token(refresh_token)
except ValueError:
raise ValueError("Invalid refresh token") from None
if payload.get("type") != "refresh":
raise ValueError("Invalid token type") from None
user_id = UUID(payload["sub"])
# Verify the user still exists before issuing new tokens
result = await self.db.execute(select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise ValueError("User no longer exists")
return self._make_token_response(user_id)
async def get_user(self, user_id: UUID) -> dict: async def get_user(self, user_id: UUID) -> dict:
from cartsnitch_api.models import User from cartsnitch_api.models import User
@@ -68,3 +115,11 @@ class AuthService:
await self.db.delete(user) await self.db.delete(user)
await self.db.commit() await self.db.commit()
def _make_token_response(self, user_id: UUID) -> dict:
return {
"access_token": create_access_token(user_id),
"refresh_token": create_refresh_token(user_id),
"token_type": "bearer",
"expires_in": settings.jwt_access_token_expire_minutes * 60,
}
+15 -101
View File
@@ -1,16 +1,8 @@
"""Shared test fixtures with in-memory SQLite database. """Shared test fixtures with in-memory SQLite database."""
Session-based auth: tests create users and sessions directly in the DB,
matching the Better-Auth session validation flow.
"""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine, event, text from sqlalchemy import create_engine, event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -59,46 +51,6 @@ async def db_engine():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Create Better-Auth tables (not managed by SQLAlchemy models)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
id_token TEXT,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
yield engine yield engine
@@ -133,55 +85,17 @@ async def client(db_engine):
app.dependency_overrides.clear() app.dependency_overrides.clear()
async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]:
"""Create a test user and a valid session directly in the DB.
Returns (user_dict, session_token).
"""
user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com")
display_name = user_overrides.get("display_name", "Test User")
session_token = secrets.token_urlsafe(32)
session_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, :email_verified, :created_at, :updated_at)"
),
{
"id": user_id,
"email": email,
"hashed_password": "not-used-with-better-auth",
"display_name": display_name,
"email_verified": False,
"created_at": now,
"updated_at": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
),
{
"id": session_id,
"token": session_token,
"user_id": user_id,
"expires_at": expires,
"created_at": now,
"updated_at": now,
},
)
return {"id": user_id, "email": email, "display_name": display_name}, session_token
@pytest.fixture @pytest.fixture
async def auth_headers(client, db_engine): async def auth_headers(client):
"""Create a test user with a valid session and return auth headers.""" """Register a test user and return auth headers."""
_, session_token = await _create_test_user_and_session(client, db_engine) resp = await client.post(
return {"Cookie": f"better-auth.session_token={session_token}"} "/auth/register",
json={
"email": "test@example.com",
"password": "testpass123",
"display_name": "Test User",
},
)
assert resp.status_code == 201
token = resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
+165 -79
View File
@@ -1,13 +1,146 @@
"""Integration tests for auth profile endpoints. """Integration tests for auth endpoints."""
Registration, login, and session management are handled by the Better-Auth
service. These tests cover the profile endpoints (GET/PATCH/DELETE /auth/me)
which validate sessions via the shared sessions table.
"""
import pytest import pytest
@pytest.mark.asyncio
async def test_register_success(client):
resp = await client.post(
"/auth/register",
json={
"email": "new@example.com",
"password": "securepass123",
"display_name": "New User",
},
)
assert resp.status_code == 201
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert data["expires_in"] == 900 # 15 min * 60
@pytest.mark.asyncio
async def test_register_duplicate_email(client):
await client.post(
"/auth/register",
json={
"email": "dupe@example.com",
"password": "securepass123",
"display_name": "User One",
},
)
resp = await client.post(
"/auth/register",
json={
"email": "dupe@example.com",
"password": "securepass456",
"display_name": "User Two",
},
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_register_short_password(client):
resp = await client.post(
"/auth/register",
json={
"email": "short@example.com",
"password": "short",
"display_name": "Short Pass",
},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_login_success(client):
await client.post(
"/auth/register",
json={
"email": "login@example.com",
"password": "securepass123",
"display_name": "Login User",
},
)
resp = await client.post(
"/auth/login",
json={
"email": "login@example.com",
"password": "securepass123",
},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
@pytest.mark.asyncio
async def test_login_wrong_password(client):
await client.post(
"/auth/register",
json={
"email": "wrong@example.com",
"password": "securepass123",
"display_name": "Wrong Pass",
},
)
resp = await client.post(
"/auth/login",
json={
"email": "wrong@example.com",
"password": "badpassword1",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_user(client):
resp = await client.post(
"/auth/login",
json={
"email": "ghost@example.com",
"password": "doesntmatter",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token(client):
reg = await client.post(
"/auth/register",
json={
"email": "refresh@example.com",
"password": "securepass123",
"display_name": "Refresh User",
},
)
refresh_token = reg.json()["refresh_token"]
resp = await client.post(
"/auth/refresh",
json={
"refresh_token": refresh_token,
},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
@pytest.mark.asyncio
async def test_refresh_with_invalid_token(client):
resp = await client.post(
"/auth/refresh",
json={
"refresh_token": "invalid.token.here",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me(client, auth_headers): async def test_get_me(client, auth_headers):
resp = await client.get("/auth/me", headers=auth_headers) resp = await client.get("/auth/me", headers=auth_headers)
@@ -22,32 +155,7 @@ async def test_get_me(client, auth_headers):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_me_unauthorized(client): async def test_get_me_unauthorized(client):
resp = await client.get("/auth/me") resp = await client.get("/auth/me")
assert resp.status_code in (401, 403) assert resp.status_code in (401, 403) # No auth header
@pytest.mark.asyncio
async def test_get_me_invalid_session(client):
resp = await client.get(
"/auth/me",
headers={"Cookie": "better-auth.session_token=invalid-token"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_get_me_with_bearer_token(client, db_engine):
"""Session tokens can also be passed as Bearer tokens for API clients."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@example.com", display_name="Bearer User"
)
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -55,7 +163,9 @@ async def test_update_me(client, auth_headers):
resp = await client.patch( resp = await client.patch(
"/auth/me", "/auth/me",
headers=auth_headers, headers=auth_headers,
json={"display_name": "Updated Name"}, json={
"display_name": "Updated Name",
},
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["display_name"] == "Updated Name" assert resp.json()["display_name"] == "Updated Name"
@@ -66,58 +176,34 @@ async def test_delete_me(client, auth_headers):
resp = await client.delete("/auth/me", headers=auth_headers) resp = await client.delete("/auth/me", headers=auth_headers)
assert resp.status_code == 204 assert resp.status_code == 204
# Session is still valid but user is gone # Verify user is gone (token still valid but user deleted)
resp = await client.get("/auth/me", headers=auth_headers) resp = await client.get("/auth/me", headers=auth_headers)
assert resp.status_code == 404 assert resp.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_expired_session_rejected(client, db_engine): async def test_refresh_after_delete_fails(client):
"""Expired sessions must be rejected.""" """Refresh token for a deleted user must be rejected."""
import secrets reg = await client.post(
import uuid "/auth/register",
from datetime import UTC, datetime, timedelta json={
"email": "ghost@example.com",
"password": "securepass123",
"display_name": "Ghost User",
},
)
tokens = reg.json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
from sqlalchemy import text # Delete the user
resp = await client.delete("/auth/me", headers=headers)
assert resp.status_code == 204
user_id = str(uuid.uuid4()) # Refresh token should now fail
session_token = secrets.token_urlsafe(32) resp = await client.post(
now = datetime.now(UTC).isoformat() "/auth/refresh",
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat() json={
"refresh_token": tokens["refresh_token"],
async with db_engine.begin() as conn: },
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@example.com",
"hp": "unused",
"dn": "Expired User",
"ev": False,
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
) )
assert resp.status_code == 401 assert resp.status_code == 401
+5 -11
View File
@@ -10,9 +10,9 @@ from decimal import Decimal
from uuid import UUID from uuid import UUID
import pytest import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.auth.jwt import decode_token
from cartsnitch_api.models import ( from cartsnitch_api.models import (
Coupon, Coupon,
NormalizedProduct, NormalizedProduct,
@@ -126,16 +126,10 @@ async def seed_data(db_engine, auth_headers):
session.add_all(prices) session.add_all(prices)
await session.flush() await session.flush()
# -- Get the user_id from the session token in auth_headers -- # -- Purchases (need the user_id from the registered test user) --
cookie_str = auth_headers.get("Cookie", "") token = auth_headers["Authorization"].split(" ")[1]
session_token = cookie_str.split("=", 1)[1] if "=" in cookie_str else "" payload = decode_token(token)
user_id = UUID(payload["sub"])
result = await session.execute(
text("SELECT user_id FROM sessions WHERE token = :token"),
{"token": session_token},
)
row = result.first()
user_id = UUID(row[0])
purchase1 = Purchase( purchase1 = Purchase(
user_id=user_id, user_id=user_id,
+151 -100
View File
@@ -1,104 +1,133 @@
"""E2E: Auth and session validation flows. """E2E: Auth and token validation flows."""
Registration and login are handled by the Better-Auth service. import asyncio
These tests validate session token handling at the API gateway level.
"""
import pytest import pytest
from tests.conftest import _create_test_user_and_session
@pytest.mark.asyncio
class TestAuthRegistrationLogin:
"""Full registration → login → token refresh → profile flow."""
async def test_full_auth_lifecycle(self, client, db_engine):
"""Register → login → get profile → refresh → get profile again."""
# Register
reg = await client.post(
"/auth/register",
json={
"email": "lifecycle@example.com",
"password": "securepass123",
"display_name": "Lifecycle User",
},
)
assert reg.status_code == 201
tokens = reg.json()
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "bearer"
assert tokens["expires_in"] > 0
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
# Get profile with access token
me = await client.get("/auth/me", headers=headers)
assert me.status_code == 200
assert me.json()["email"] == "lifecycle@example.com"
assert me.json()["display_name"] == "Lifecycle User"
# Sleep 1s so the new token has a different exp than the registration token
await asyncio.sleep(1)
# Login with same credentials
login = await client.post(
"/auth/login",
json={"email": "lifecycle@example.com", "password": "securepass123"},
)
assert login.status_code == 200
login_tokens = login.json()
assert login_tokens["access_token"] != tokens["access_token"]
# Refresh token
refresh = await client.post(
"/auth/refresh",
json={"refresh_token": tokens["refresh_token"]},
)
assert refresh.status_code == 200
new_tokens = refresh.json()
assert new_tokens["access_token"] != tokens["access_token"]
# Use refreshed token to access profile
new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"}
me2 = await client.get("/auth/me", headers=new_headers)
assert me2.status_code == 200
assert me2.json()["email"] == "lifecycle@example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
class TestSessionValidation: class TestTokenValidation:
"""Session edge cases and error responses.""" """Token edge cases and error responses."""
async def test_invalid_session_token_rejected(self, client, db_engine): async def test_expired_token_rejected(self, client, db_engine):
resp = await client.get( """Manually craft an expired token and verify rejection."""
"/auth/me",
headers={"Cookie": "better-auth.session_token=not-a-real-token"},
)
assert resp.status_code == 401
async def test_missing_auth(self, client, db_engine):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
async def test_bearer_token_also_works(self, client, db_engine):
"""Session tokens passed as Bearer tokens should also be accepted."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@e2e.com", display_name="Bearer E2E"
)
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@e2e.com"
async def test_deleted_user_session_returns_not_found(self, client, db_engine):
"""After deleting a user, their session should result in 404 for profile."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="delete-me@e2e.com", display_name="Delete Me"
)
headers = {"Cookie": f"better-auth.session_token={session_token}"}
delete_resp = await client.delete("/auth/me", headers=headers)
assert delete_resp.status_code == 204
me = await client.get("/auth/me", headers=headers)
assert me.status_code == 404
async def test_expired_session_rejected(self, client, db_engine):
"""Expired sessions must be rejected."""
import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from sqlalchemy import text from jose import jwt
user_id = str(uuid.uuid4()) from cartsnitch_api.config import settings
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
async with db_engine.begin() as conn: payload = {
await conn.execute( "sub": str(uuid.uuid4()),
text( "exp": datetime.now(UTC) - timedelta(minutes=5),
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) " "type": "access",
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)" }
), token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
{ resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
"id": user_id,
"email": "expired@e2e.com",
"hp": "unused",
"dn": "Expired User",
"ev": False,
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
assert resp.status_code == 401 assert resp.status_code == 401
async def test_invalid_token_rejected(self, client, db_engine):
resp = await client.get("/auth/me", headers={"Authorization": "Bearer not-a-real-token"})
assert resp.status_code == 401
async def test_missing_auth_header(self, client, db_engine):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
async def test_refresh_token_cannot_access_endpoints(self, client, db_engine):
"""A refresh token should not work as an access token."""
reg = await client.post(
"/auth/register",
json={
"email": "refresh-test@example.com",
"password": "securepass123",
"display_name": "Refresh Test",
},
)
refresh_token = reg.json()["refresh_token"]
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {refresh_token}"})
assert resp.status_code == 401
async def test_deleted_user_token_invalid(self, client, db_engine):
"""After deleting an account, tokens should no longer work."""
reg = await client.post(
"/auth/register",
json={
"email": "delete-me@example.com",
"password": "securepass123",
"display_name": "Delete Me",
},
)
tokens = reg.json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
# Delete account
delete_resp = await client.delete("/auth/me", headers=headers)
assert delete_resp.status_code == 204
# Profile should fail
me = await client.get("/auth/me", headers=headers)
assert me.status_code in (401, 404)
@pytest.mark.asyncio @pytest.mark.asyncio
class TestAuthProtectedEndpoints: class TestAuthProtectedEndpoints:
@@ -125,38 +154,60 @@ class TestAuthProtectedEndpoints:
class TestCrossUserDataIsolation: class TestCrossUserDataIsolation:
"""Verify that users cannot access other users' data.""" """Verify that users cannot access other users' data."""
async def test_user_b_cannot_access_user_a_purchases(self, client, db_engine, seed_data): async def test_user_b_cannot_access_user_a_purchases(self, client, seed_data):
"""A second user cannot see User A's purchases.""" """Register a second user and verify they cannot see User A's purchases."""
# User A's purchase (from seed_data)
purchase_id = str(seed_data["purchases"]["meijer_trip"].id) purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
_, session_token = await _create_test_user_and_session( # Register User B
client, db_engine, email="userb@e2e.com", display_name="User B" reg = await client.post(
"/auth/register",
json={
"email": "userb@example.com",
"password": "securepass123",
"display_name": "User B",
},
) )
user_b_headers = {"Cookie": f"better-auth.session_token={session_token}"} assert reg.status_code == 201
user_b_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
# User B tries to access User A's specific purchase
resp = await client.get(f"/purchases/{purchase_id}", headers=user_b_headers) resp = await client.get(f"/purchases/{purchase_id}", headers=user_b_headers)
assert resp.status_code in (403, 404), ( assert resp.status_code in (403, 404), (
"User B should not be able to access User A's purchase" "User B should not be able to access User A's purchase"
) )
async def test_user_b_purchase_list_is_empty(self, client, db_engine, seed_data): async def test_user_b_purchase_list_is_empty(self, client, seed_data):
"""A new user should see no purchases.""" """A new user should see no purchases (not User A's purchases)."""
_, session_token = await _create_test_user_and_session( reg = await client.post(
client, db_engine, email="userc@e2e.com", display_name="User C" "/auth/register",
json={
"email": "userc@example.com",
"password": "securepass123",
"display_name": "User C",
},
) )
user_c_headers = {"Cookie": f"better-auth.session_token={session_token}"} assert reg.status_code == 201
user_c_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
resp = await client.get("/purchases", headers=user_c_headers) resp = await client.get("/purchases", headers=user_c_headers)
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no purchases" assert len(resp.json()) == 0, "New user should have no purchases"
async def test_user_b_stores_isolated(self, client, db_engine, seed_data): async def test_user_b_stores_isolated(self, client, seed_data):
"""User B's connected stores should be independent from User A.""" """User B's connected stores should be independent from User A."""
_, session_token = await _create_test_user_and_session( reg = await client.post(
client, db_engine, email="userd@e2e.com", display_name="User D" "/auth/register",
json={
"email": "userd@example.com",
"password": "securepass123",
"display_name": "User D",
},
) )
user_d_headers = {"Cookie": f"better-auth.session_token={session_token}"} assert reg.status_code == 201
user_d_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
# User D should have no connected stores
resp = await client.get("/me/stores", headers=user_d_headers) resp = await client.get("/me/stores", headers=user_d_headers)
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no connected stores" assert len(resp.json()) == 0, "New user should have no connected stores"
+13 -32
View File
@@ -1,25 +1,26 @@
"""Integration tests for purchase endpoints.""" """Integration tests for purchase endpoints."""
import secrets
import uuid import uuid
from datetime import UTC, date, datetime, timedelta from datetime import date
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.auth.jwt import create_access_token
from cartsnitch_api.models import Purchase, PurchaseItem, Store, User from cartsnitch_api.models import Purchase, PurchaseItem, Store, User
@pytest.fixture @pytest.fixture
async def purchase_data(db_engine): async def purchase_data(db_engine):
"""Seed a user, store, purchase, items, and a valid session.""" """Seed a user, store, purchase, and items."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session: async with factory() as session:
from cartsnitch_api.auth.passwords import hash_password
user = User( user = User(
email="buyer@example.com", email="buyer@example.com",
hashed_password="not-used-with-better-auth", hashed_password=hash_password("testpass123"),
display_name="Buyer", display_name="Buyer",
) )
store = Store(name="Kroger", slug="kroger") store = Store(name="Kroger", slug="kroger")
@@ -49,33 +50,13 @@ async def purchase_data(db_engine):
session.add(item) session.add(item)
await session.commit() await session.commit()
# Create a session token directly in the sessions table token = create_access_token(user.id)
session_token = secrets.token_urlsafe(32) return {
now = datetime.now(UTC).isoformat() "user": user,
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat() "store": store,
"purchase": purchase,
async with db_engine.begin() as conn: "headers": {"Authorization": f"Bearer {token}"},
await conn.execute( }
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"user_id": str(user.id),
"expires_at": expires,
"created_at": now,
"updated_at": now,
},
)
return {
"user": user,
"store": store,
"purchase": purchase,
"headers": {"Cookie": f"better-auth.session_token={session_token}"},
}
@pytest.mark.asyncio @pytest.mark.asyncio
-11
View File
@@ -1,11 +0,0 @@
# Required: Generate with `openssl rand -base64 32`
BETTER_AUTH_SECRET=change-me-in-production-min-32-chars!!
# Base URL of the auth service
BETTER_AUTH_URL=http://localhost:3001
# Shared PostgreSQL database
DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch
# Port the auth service listens on
PORT=3001
-17
View File
@@ -1,17 +0,0 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist/ dist/
USER 101
EXPOSE 3001
CMD ["node", "dist/index.js"]
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@cartsnitch/auth",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"generate": "npx @better-auth/cli generate"
},
"dependencies": {
"better-auth": "^1.2.0",
"pg": "^8.13.0",
"bcrypt": "^5.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"@types/bcrypt": "^5.0.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
-90
View File
@@ -1,90 +0,0 @@
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL ??
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
});
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
export const auth = betterAuth({
database: pool,
basePath: "/auth",
secret,
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3001",
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 128,
password: {
hash: async (password: string) => {
return bcrypt.hash(password, 10);
},
verify: async (data: { hash: string; password: string }) => {
return bcrypt.compare(data.password, data.hash);
},
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh after 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5-minute cookie cache
},
},
user: {
modelName: "users",
fields: {
name: "display_name",
emailVerified: "email_verified",
image: "image",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
account: {
modelName: "accounts",
fields: {
userId: "user_id",
accountId: "account_id",
providerId: "provider_id",
accessToken: "access_token",
refreshToken: "refresh_token",
accessTokenExpiresAt: "access_token_expires_at",
refreshTokenExpiresAt: "refresh_token_expires_at",
idToken: "id_token",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
verification: {
modelName: "verifications",
fields: {
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
trustedOrigins: [
"http://localhost:3000",
"http://localhost:5173",
"https://cartsnitch.com",
"https://cartsnitch.farh.net",
"https://cartsnitch.dev.farh.net",
],
});
-23
View File
@@ -1,23 +0,0 @@
import { createServer } from "node:http";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth.js";
const port = parseInt(process.env.PORT ?? "3001", 10);
const handler = toNodeHandler(auth);
const server = createServer(async (req, res) => {
// Health check
if (req.url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
// All /auth/* routes handled by Better-Auth
await handler(req, res);
});
server.listen(port, "0.0.0.0", () => {
console.log(`CartSnitch auth service listening on port ${port}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+1 -3
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import AccountStatus from cartsnitch_common.constants import AccountStatus
@@ -23,8 +23,6 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
image: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships # Relationships
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user") store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user")
@@ -0,0 +1,53 @@
---
title: "CartSnitch vs Flipp: Which App Actually Helps You Save More on Groceries?"
slug: cartsnitch-vs-flipp
status: draft
version: 1.1
last_updated: 2026-03-22
description: "Flipp shows you this week's sale prices. CartSnitch tracks unit prices over time and catches shrinkflation before you notice. Here's when each tool wins."
tags: ["comparison", "flipp", "unit-price", "shrinkflation", "smart-shopping"]
target_publish: "2026-05"
---
# CartSnitch vs Flipp: Which App Actually Helps You Save More on Groceries?
Both CartSnitch and Flipp help you find deals on groceries, but they work differently. Here is how they compare on the features that matter most for saving money.
## What Is Flipp?
Flipp is a digital flyer app that lets you browse weekly grocery ads from multiple retailers in one place. You can clip coupons and create a shopping list from featured deals.
## What Is CartSnitch?
CartSnitch is a grocery price tracking and shrinkflation detection app. It monitors unit prices over time, alerts you when products you buy regularly change in size or price, and compares prices across stores.
## Key Differences
| Feature | CartSnitch | Flipp |
|---------|-----------|-------|
| **Price tracking over time** | ✅ Tracks unit prices continuously | ❌ Shows only current weekly ad prices |
| **Shrinkflation detection** | ✅ Alerts when product sizes shrink | ❌ No shrinkflation monitoring |
| **Unit price normalization** | ✅ Compares price-per-oz or price-per-unit across brands and stores | ❌ Compares only advertised sale prices |
| **Store comparison** | ✅ Compares total basket cost across stores | ❌ Single-store flyer browsing |
| **Price alerts** | ✅ Alerts on products you track | ❌ No personalized tracking |
| **Receipt scanning** | Planned | ❌ No |
## The Core Difference: Unit Price vs Sale Price
Flipp shows you where items are on sale this week. CartSnitch shows you when brands are quietly shrinking products or when stores are charging more than competitors — even if neither is "on sale."
**Example:** A cereal brand reduces its box from 18 oz to 15.5 oz. The shelf price stays the same. Flipp shows no deal. CartSnitch flags it as a 16.1% unit price increase.
This is shrinkflation. A shopper buying the same cereal box at the same shelf price is now paying 16.1% more per ounce — without any price tag ever changing.
## Which App Saves You More?
**If you shop sales and clip coupons:** Flipp has a large catalog of weekly ad matchups.
**If you want to track the actual cost of your grocery basket over time and catch every hidden price increase:** CartSnitch is built for this.
Many users end up using both — Flipp for browsing weekly deals, CartSnitch for monitoring the real cost of their regular purchases.
## Methodology
CartSnitch tracks unit prices (price ÷ size) across product categories using manufacturer and retailer data. Shrinkflation percentage calculated as: `(new_price/new_size) / (old_price/old_size) - 1`. Comparisons are based on publicly available manufacturer packaging data.
@@ -0,0 +1,70 @@
---
title: "What Is Unit Price and How Do You Calculate It?"
slug: what-is-unit-price
status: draft
version: 1.0
last_updated: 2026-03-22
description: "Unit price is the cost per ounce, gram, or sheet — the number that reveals which product is actually the better deal, and exposes shrinkflation before you realize you're paying more."
tags: ["unit-price", "shrinkflation", "grocery-prices", "smart-shopping", "explainer"]
---
# What Is Unit Price and How Do You Calculate It?
When you see two products on a shelf at different prices, the obvious move is to pick the cheaper one. But what if the cheaper item is actually a worse deal? Unit price is the metric that tells you the truth.
## What Is Unit Price?
Unit price is the cost of an item per standard unit of measurement — price per ounce, price per gram, price per sheet, price per load. It lets you compare products of different sizes against each other fairly.
Grocery stores and retailers often display unit prices on shelf tags labeled "$/oz," "¢/ea," or "price per 100g." You can also calculate it yourself.
## How to Calculate Unit Price
**Formula:** `Unit Price = Item Price ÷ Size`
**Examples:**
- Product A: $4.99 for 16 oz → $4.99 ÷ 16 = $0.31 per oz
- Product B: $3.99 for 12 oz → $3.99 ÷ 12 = $0.33 per oz
Product A costs more upfront ($4.99 vs $3.99) but is actually the better value at $0.31/oz vs $0.33/oz.
## Unit Price vs Shelf Price
| Term | Definition |
|------|------------|
| **Shelf price** | The total price you pay at checkout |
| **Unit price** | Price divided by size — the true cost per useable unit |
Shelf price misleads you when product sizes vary. Unit price reveals the actual cost regardless of packaging.
## Why Unit Price Matters: The Shrinkflation Example
Brands know unit price is how smart shoppers compare. Instead of raising shelf prices (which shoppers notice), they shrink the product. The shelf price stays the same. The unit price goes up.
**Real example:**
- 2021: Cereal box — 18 oz at $4.99 → $0.277/oz
- 2024: Same brand, same shelf price — 15.5 oz at $4.99 → $0.322/oz
The shelf price did not change. The unit price went up 16.1%. You are paying 16.1% more per ounce for the same product without realizing it.
This is shrinkflation, and it is happening across cereals, snacks, dairy, household products, and more.
## How to Use Unit Price at the Grocery Store
1. **Look for the small print** — Most stores label unit price on the shelf tag. Find the "$/oz" or "¢/load" number.
2. **Calculate yourself** — Divide shelf price by size (oz, g, sheets, loads). Write it down or use a phone calculator.
3. **Compare across brands** — The brand with the lower shelf price is not always the lower unit price.
4. **Track it over time** — If you buy the same products regularly, unit price changes reveal shrinkflation before the brand announces it.
## Unit Price and CartSnitch
CartSnitch automatically calculates unit prices for the products you track. When a brand shrinks a product, CartSnitch flags the unit price increase so you see exactly how much more you are paying per ounce — even if the shelf price never changed.
## Summary
Unit price is the most honest way to compare products of different sizes. It reveals shrinkflation, exposes hidden price increases, and helps you make truly informed purchasing decisions. The formula is simple: divide the price by the size.
**Quick reference:**
- Shelf price: What you pay
- Unit price: What you pay per ounce/gram/unit — the real measure of value
-1
View File
@@ -13,7 +13,6 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
+2 -17
View File
@@ -1,25 +1,10 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() { export function ProtectedRoute() {
const { data: session, isPending } = authClient.useSession() const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
useEffect(() => { if (!isAuthenticated) {
setAuthenticated(!!session)
}, [session, setAuthenticated])
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
</div>
)
}
if (!session) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
+100 -98
View File
@@ -1,98 +1,100 @@
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { import {
mockPurchases, mockPurchases,
mockProducts, mockProducts,
mockCoupons, mockCoupons,
mockAlerts, mockAlerts,
getMockPriceHistory, getMockPriceHistory,
} from './mock-data.ts' } from './mock-data.ts'
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1' const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true' const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
// Mock response lookup table // Mock response lookup table
const mockRoutes: Record<string, (path: string) => unknown> = { const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases, '/purchases': () => mockPurchases,
'/products': () => mockProducts, '/products': () => mockProducts,
'/coupons': () => mockCoupons, '/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts, '/price-alerts': () => mockAlerts,
} }
function matchMockRoute<T>(path: string): T | null { function matchMockRoute<T>(path: string): T | null {
// Exact match // Exact match
if (mockRoutes[path]) return mockRoutes[path](path) as T if (mockRoutes[path]) return mockRoutes[path](path) as T
// /purchases/:id // /purchases/:id
const purchaseMatch = path.match(/^\/purchases\/(.+)$/) const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
if (purchaseMatch) { if (purchaseMatch) {
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1]) const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
return (purchase ?? null) as T return (purchase ?? null) as T
} }
// /products/:id/price-history // /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/) const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) { if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T return getMockPriceHistory(priceHistoryMatch[1]) as T
} }
// /products/:id // /products?q=search or /products/:id
const productMatch = path.match(/^\/products\/(.+)$/) const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) { if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1]) const product = mockProducts.find((p) => p.id === productMatch[1])
return (product ?? null) as T return (product ?? null) as T
} }
const productsSearch = path.match(/^\/products\?q=(.+)$/) const productsSearch = path.match(/^\/products\?q=(.+)$/)
if (productsSearch) { if (productsSearch) {
const q = decodeURIComponent(productsSearch[1]).toLowerCase() const q = decodeURIComponent(productsSearch[1]).toLowerCase()
return mockProducts.filter( return mockProducts.filter(
(p) => (p) =>
p.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q), p.category.toLowerCase().includes(q),
) as T ) as T
} }
return null return null
} }
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> { async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
// Mock interceptor: return mock data without hitting the network // Mock interceptor: return mock data without hitting the network
if (USE_MOCK && (!options?.method || options.method === 'GET')) { if (USE_MOCK && (!options?.method || options.method === 'GET')) {
const mockResult = matchMockRoute<T>(path) const mockResult = matchMockRoute<T>(path)
if (mockResult !== null) { if (mockResult !== null) {
// Simulate network delay for realistic loading states // Simulate network delay for realistic loading states
await new Promise((r) => setTimeout(r, 300)) await new Promise((r) => setTimeout(r, 300))
return mockResult return mockResult
} }
} }
const res = await fetch(`${API_BASE}${path}`, { const token = useAuthStore.getState().token
...options,
credentials: 'include', // Send Better-Auth session cookie const res = await fetch(`${API_BASE}${path}`, {
headers: { ...options,
'Content-Type': 'application/json', headers: {
...options?.headers, 'Content-Type': 'application/json',
}, ...(token ? { Authorization: `Bearer ${token}` } : {}),
}) ...options?.headers,
},
if (res.status === 401) { })
useAuthStore.getState().setAuthenticated(false)
throw new Error('Unauthorized') if (res.status === 401) {
} useAuthStore.getState().logout()
throw new Error('Unauthorized')
if (!res.ok) { }
throw new Error(`API error: ${res.status}`)
} if (!res.ok) {
throw new Error(`API error: ${res.status}`)
return res.json() as Promise<T> }
}
return res.json() as Promise<T>
export const api = { }
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) => export const api = {
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }), get: <T>(path: string) => apiFetch<T>(path),
put: <T>(path: string, body: unknown) => post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }), apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }), put: <T>(path: string, body: unknown) =>
} apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
}
-8
View File
@@ -1,8 +0,0 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
basePath: "/auth",
})
export const { useSession, signIn, signUp, signOut } = authClient
+197 -200
View File
@@ -1,200 +1,197 @@
import React, { Suspense } from 'react' 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 { useAuthStore } from '../stores/auth.ts'
import { usePurchases, usePriceAlerts, usePriceHistory } 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(() => const LazySparklineCard = React.lazy(() =>
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard })) import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
) )
export function Dashboard() { export function Dashboard() {
const { data: session, isPending } = authClient.useSession() const user = useAuthStore((s) => s.user)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (isPending) {
return <DashboardSkeleton /> if (!isAuthenticated) {
} return (
<div className="py-8 text-center">
if (!session) { <h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
return ( <p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
<div className="py-8 text-center"> <div className="mt-8 space-y-3">
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1> <Link
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p> to="/login"
<div className="mt-8 space-y-3"> className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
<Link >
to="/login" Sign In
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90" </Link>
> <Link
Sign In to="/register"
</Link> className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
<Link >
to="/register" Create Account
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50" </Link>
> </div>
Create Account </div>
</Link> )
</div> }
</div>
) return <AuthenticatedDashboard userName={user?.name ?? 'there'} />
} }
return <AuthenticatedDashboard userName={session.user?.name ?? 'there'} /> function AuthenticatedDashboard({ userName }: { userName: string }) {
} const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
function AuthenticatedDashboard({ userName }: { userName: string }) { const { data: eggHistory = [] } = usePriceHistory('prod10')
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases() const { data: milkHistory = [] } = usePriceHistory('prod1')
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
const { data: eggHistory = [] } = usePriceHistory('prod10') const triggeredAlerts = alerts.filter((a) => a.triggered)
const { data: milkHistory = [] } = usePriceHistory('prod1') const watchingAlerts = alerts.filter((a) => !a.triggered)
const recentPurchases = purchases.slice(0, 3)
const triggeredAlerts = alerts.filter((a) => a.triggered)
const watchingAlerts = alerts.filter((a) => !a.triggered) const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
const recentPurchases = purchases.slice(0, 3) const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8) const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8) const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—' if (purchasesLoading || alertsLoading) {
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—' return <DashboardSkeleton />
}
if (purchasesLoading || alertsLoading) {
return <DashboardSkeleton /> return (
} <div>
<h1 className="text-2xl font-bold text-gray-900">
return ( Hi, {userName.split(' ')[0]}
<div> </h1>
<h1 className="text-2xl font-bold text-gray-900">
Hi, {userName.split(' ')[0]} {/* Triggered alerts banner */}
</h1> {triggeredAlerts.length > 0 && (
<Link
{/* Triggered alerts banner */} to="/alerts"
{triggeredAlerts.length > 0 && ( className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
<Link >
to="/alerts" <span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4" &#x2713;
> </span>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white"> <div>
&#x2713; <p className="text-sm font-semibold text-green-800">
</span> {triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
<div> </p>
<p className="text-sm font-semibold text-green-800"> <p className="text-xs text-green-700">
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered! {triggeredAlerts.map((a) => a.productName).join(', ')}
</p> </p>
<p className="text-xs text-green-700"> </div>
{triggeredAlerts.map((a) => a.productName).join(', ')} </Link>
</p> )}
</div>
</Link> {/* Quick stats */}
)} <div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-xl bg-white p-4 shadow-sm">
{/* Quick stats */} <p className="text-xs font-medium text-gray-500">Watching</p>
<div className="mt-4 grid grid-cols-2 gap-3"> <p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
<div className="rounded-xl bg-white p-4 shadow-sm"> <p className="text-xs text-gray-400">price alerts</p>
<p className="text-xs font-medium text-gray-500">Watching</p> </div>
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p> <div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs text-gray-400">price alerts</p> <p className="text-xs font-medium text-gray-500">This Month</p>
</div> <p className="mt-1 text-2xl font-bold text-gray-900">
<div className="rounded-xl bg-white p-4 shadow-sm"> ${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
<p className="text-xs font-medium text-gray-500">This Month</p> </p>
<p className="mt-1 text-2xl font-bold text-gray-900"> <p className="text-xs text-gray-400">grocery spend</p>
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)} </div>
</p> </div>
<p className="text-xs text-gray-400">grocery spend</p>
</div> {/* Price trend sparklines */}
</div> <section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
{/* Price trend sparklines */} <div className="space-y-3">
<section className="mt-6"> <Suspense fallback={<SparklinePlaceholder />}>
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2> <LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
<div className="space-y-3"> <LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
<Suspense fallback={<SparklinePlaceholder />}> </Suspense>
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} /> </div>
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} /> </section>
</Suspense>
</div> {/* Recent purchases */}
</section> <section className="mt-6">
<div className="flex items-center justify-between">
{/* Recent purchases */} <h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
<section className="mt-6"> <Link to="/purchases" className="text-sm text-brand-blue">
<div className="flex items-center justify-between"> View all
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2> </Link>
<Link to="/purchases" className="text-sm text-brand-blue"> </div>
View all <div className="mt-3 space-y-3">
</Link> {recentPurchases.map((purchase) => (
</div> <Link
<div className="mt-3 space-y-3"> key={purchase.id}
{recentPurchases.map((purchase) => ( to={`/purchases/${purchase.id}`}
<Link className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
key={purchase.id} >
to={`/purchases/${purchase.id}`} <StoreIcon storeId={purchase.storeId} />
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50" <div className="min-w-0 flex-1">
> <p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
<StoreIcon storeId={purchase.storeId} /> <p className="text-xs text-gray-500">
<div className="min-w-0 flex-1"> {new Date(purchase.date).toLocaleDateString('en-US', {
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p> month: 'short',
<p className="text-xs text-gray-500"> day: 'numeric',
{new Date(purchase.date).toLocaleDateString('en-US', { })}{' '}
month: 'short', &middot; {purchase.items.length} items
day: 'numeric', </p>
})}{' '} </div>
&middot; {purchase.items.length} items <span className="text-sm font-semibold text-gray-900">
</p> ${purchase.total.toFixed(2)}
</div> </span>
<span className="text-sm font-semibold text-gray-900"> </Link>
${purchase.total.toFixed(2)} ))}
</span> </div>
</Link> </section>
))}
</div> {/* Quick actions */}
</section> <section className="mt-6 pb-4">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
{/* Quick actions */} <div className="grid grid-cols-2 gap-3">
<section className="mt-6 pb-4"> <Link
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2> to="/products"
<div className="grid grid-cols-2 gap-3"> className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
<Link >
to="/products" Compare Prices
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50" </Link>
> <Link
Compare Prices to="/settings"
</Link> className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
<Link >
to="/settings" Link a Store
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50" </Link>
> </div>
Link a Store </section>
</Link> </div>
</div> )
</section> }
</div>
) function DashboardSkeleton() {
} return (
<div className="animate-pulse">
function DashboardSkeleton() { <div className="h-8 w-40 rounded bg-gray-200" />
return ( <div className="mt-4 grid grid-cols-2 gap-3">
<div className="animate-pulse"> <div className="h-24 rounded-xl bg-gray-200" />
<div className="h-8 w-40 rounded bg-gray-200" /> <div className="h-24 rounded-xl bg-gray-200" />
<div className="mt-4 grid grid-cols-2 gap-3"> </div>
<div className="h-24 rounded-xl bg-gray-200" /> <div className="mt-6 h-5 w-28 rounded bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" /> <div className="mt-3 space-y-3">
</div> <div className="h-16 rounded-xl bg-gray-200" />
<div className="mt-6 h-5 w-28 rounded bg-gray-200" /> <div className="h-16 rounded-xl bg-gray-200" />
<div className="mt-3 space-y-3"> </div>
<div className="h-16 rounded-xl bg-gray-200" /> </div>
<div className="h-16 rounded-xl bg-gray-200" /> )
</div> }
</div>
) function SparklinePlaceholder() {
} return (
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
function SparklinePlaceholder() { <div className="min-w-0 flex-1">
return ( <div className="h-4 w-24 rounded bg-gray-200" />
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse"> <div className="mt-2 h-6 w-16 rounded bg-gray-200" />
<div className="min-w-0 flex-1"> </div>
<div className="h-4 w-24 rounded bg-gray-200" /> <div className="h-10 w-24 rounded bg-gray-100" />
<div className="mt-2 h-6 w-16 rounded bg-gray-200" /> </div>
</div> )
<div className="h-10 w-24 rounded bg-gray-100" /> }
</div>
)
}
+92 -97
View File
@@ -1,97 +1,92 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts' import { useAuthStore } from '../stores/auth.ts'
import { useAuthStore } from '../stores/auth.ts' import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
export function Login() { import type { User } from '../types/api.ts'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('') export function Login() {
const [error, setError] = useState('') const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false) const [password, setPassword] = useState('')
const navigate = useNavigate() const [error, setError] = useState('')
const setAuthenticated = useAuthStore((s) => s.setAuthenticated) const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) { const setAuth = useAuthStore((s) => s.setAuth)
e.preventDefault()
setError('') async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email || !password) { setError('')
setError('Please fill in all fields.')
return if (!email || !password) {
} setError('Please fill in all fields.')
return
setLoading(true) }
try {
const { data, error: authError } = await authClient.signIn.email({ setLoading(true)
email, try {
password, const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
}) setAuth(res.user, res.token)
navigate('/')
if (authError) { } catch {
throw new Error(authError.message ?? 'Sign in failed') if (import.meta.env.VITE_MOCK_AUTH === 'true') {
} // Fallback to mock auth for demo
setAuth(mockUser, 'mock-jwt-token')
setAuthenticated(true) navigate('/')
navigate('/') } else {
} catch { setError('Invalid email or password. Please try again.')
if (import.meta.env.VITE_MOCK_AUTH === 'true') { }
setAuthenticated(true) } finally {
navigate('/') setLoading(false)
} else { }
setError('Invalid email or password. Please try again.') }
}
} finally { return (
setLoading(false) <div className="flex min-h-screen flex-col items-center justify-center px-4">
} <h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
} <p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
return ( {error && (
<div className="flex min-h-screen flex-col items-center justify-center px-4"> <div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1> {error}
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p> </div>
)}
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700"> <form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
{error} <input
</div> type="email"
)} placeholder="Email"
value={email}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}> onChange={(e) => setEmail(e.target.value)}
<input autoComplete="email"
type="email" className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
placeholder="Email" />
value={email} <input
onChange={(e) => setEmail(e.target.value)} type="password"
autoComplete="email" placeholder="Password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" value={password}
/> onChange={(e) => setPassword(e.target.value)}
<input autoComplete="current-password"
type="password" className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
placeholder="Password" />
value={password} <button
onChange={(e) => setPassword(e.target.value)} type="submit"
autoComplete="current-password" disabled={loading}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
/> >
<button {loading ? 'Signing in...' : 'Sign In'}
type="submit" </button>
disabled={loading} </form>
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
> <Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
{loading ? 'Signing in...' : 'Sign In'} Forgot password?
</button> </Link>
</form>
<p className="mt-6 text-sm text-gray-500">
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue"> Don't have an account?{' '}
Forgot password? <Link to="/register" className="text-brand-blue">
</Link> Sign up
</Link>
<p className="mt-6 text-sm text-gray-500"> </p>
Don't have an account?{' '} </div>
<Link to="/register" className="text-brand-blue"> )
Sign up }
</Link>
</p>
</div>
)
}
+102 -108
View File
@@ -1,108 +1,102 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts' import { useAuthStore } from '../stores/auth.ts'
import { useAuthStore } from '../stores/auth.ts' import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
export function Register() { import type { User } from '../types/api.ts'
const [name, setName] = useState('')
const [email, setEmail] = useState('') export function Register() {
const [password, setPassword] = useState('') const [name, setName] = useState('')
const [error, setError] = useState('') const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false) const [password, setPassword] = useState('')
const navigate = useNavigate() const [error, setError] = useState('')
const setAuthenticated = useAuthStore((s) => s.setAuthenticated) const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) { const setAuth = useAuthStore((s) => s.setAuth)
e.preventDefault()
setError('') async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || !email || !password) { setError('')
setError('Please fill in all fields.')
return if (!name || !email || !password) {
} setError('Please fill in all fields.')
return
if (password.length < 8) { }
setError('Password must be at least 8 characters.')
return if (password.length < 8) {
} setError('Password must be at least 8 characters.')
return
setLoading(true) }
try {
const { data, error: authError } = await authClient.signUp.email({ setLoading(true)
name, try {
email, const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
password, setAuth(res.user, res.token)
}) navigate('/')
} catch {
if (authError) { if (import.meta.env.VITE_MOCK_AUTH === 'true') {
throw new Error(authError.message ?? 'Registration failed') // Fallback to mock auth for demo
} setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
navigate('/')
setAuthenticated(true) } else {
navigate('/') setError('Registration failed. Please try again.')
} catch { }
if (import.meta.env.VITE_MOCK_AUTH === 'true') { } finally {
setAuthenticated(true) setLoading(false)
navigate('/') }
} else { }
setError('Registration failed. Please try again.')
} return (
} finally { <div className="flex min-h-screen flex-col items-center justify-center px-4">
setLoading(false) <h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
} <p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
}
{error && (
return ( <div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="flex min-h-screen flex-col items-center justify-center px-4"> {error}
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1> </div>
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p> )}
{error && ( <form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700"> <input
{error} type="text"
</div> placeholder="Full Name"
)} value={name}
onChange={(e) => setName(e.target.value)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}> autoComplete="name"
<input className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
type="text" />
placeholder="Full Name" <input
value={name} type="email"
onChange={(e) => setName(e.target.value)} placeholder="Email"
autoComplete="name" value={email}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" onChange={(e) => setEmail(e.target.value)}
/> autoComplete="email"
<input className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
type="email" />
placeholder="Email" <input
value={email} type="password"
onChange={(e) => setEmail(e.target.value)} placeholder="Password (min. 8 characters)"
autoComplete="email" value={password}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" onChange={(e) => setPassword(e.target.value)}
/> autoComplete="new-password"
<input className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
type="password" />
placeholder="Password (min. 8 characters)" <button
value={password} type="submit"
onChange={(e) => setPassword(e.target.value)} disabled={loading}
autoComplete="new-password" className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" >
/> {loading ? 'Creating account...' : 'Create Account'}
<button </button>
type="submit" </form>
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60" <p className="mt-6 text-sm text-gray-500">
> Already have an account?{' '}
{loading ? 'Creating account...' : 'Create Account'} <Link to="/login" className="text-brand-blue">
</button> Sign in
</form> </Link>
</p>
<p className="mt-6 text-sm text-gray-500"> </div>
Already have an account?{' '} )
<Link to="/login" className="text-brand-blue"> }
Sign in
</Link>
</p>
</div>
)
}
+5 -8
View File
@@ -1,21 +1,18 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { useThemeStore } from '../stores/theme.ts' import { useThemeStore } from '../stores/theme.ts'
import { StoreIcon } from '../components/StoreIcon.tsx' import { StoreIcon } from '../components/StoreIcon.tsx'
export function Settings() { export function Settings() {
const { data: session } = authClient.useSession() const user = useAuthStore((s) => s.user)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated) const logout = useAuthStore((s) => s.logout)
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme } = useThemeStore() const { theme, setTheme } = useThemeStore()
const user = session?.user const connectedStores = user?.connectedStores ?? []
const connectedStores: string[] = []
async function handleSignOut() { function handleSignOut() {
await authClient.signOut() logout()
setAuthenticated(false)
navigate('/login') navigate('/login')
} }
+27 -18
View File
@@ -1,18 +1,27 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/** import type { User } from '../types/api.ts'
* Minimal auth state for UI reactivity.
* interface AuthState {
* Session management is handled by Better-Auth via httpOnly cookies. user: User | null
* This store only tracks whether we have an active session for UI token: string | null
* gating (protected routes, nav state). No tokens in memory or localStorage. isAuthenticated: boolean
*/ setAuth: (user: User, token: string) => void
interface AuthState { logout: () => void
isAuthenticated: boolean }
setAuthenticated: (value: boolean) => void
} export const useAuthStore = create<AuthState>()(
persist(
export const useAuthStore = create<AuthState>()((set) => ({ (set) => ({
isAuthenticated: false, user: null,
setAuthenticated: (value) => set({ isAuthenticated: value }), token: null,
})) isAuthenticated: false,
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
logout: () => set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'cartsnitch-auth',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
},
),
)