Compare commits

..

12 Commits

Author SHA1 Message Date
Barcode Betty 6b6b9e7d01 feat: add utility functions with unit tests
Add formatCurrency, formatDate, and storeSlugs utilities in src/utils/
with 21 vitest unit tests covering standard and edge cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 18:53:40 +00:00
cartsnitch-engineer[bot] 835aff3522 fix: use same-origin default for auth URL instead of localhost
fix: use same-origin default for auth URL instead of localhost
2026-03-30 16:07:28 +00:00
Barcode Betty 5588c1b5d8 fix: use same-origin default for auth URL instead of localhost
Avoids ERR_CONNECTION_REFUSED in deployed environments where
VITE_AUTH_URL is not set at build time. Empty-string fallback
routes auth requests to same origin, which the HTTPRoute forwards
to the auth service.

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 15:50:51 +00:00
cartsnitch-ceo[bot] c5ed863ab1 fix: align frontend auth with API token response contract
fix: align frontend auth with API token response contract
2026-03-30 15:20:56 +00:00
Barcode Betty 8d0552f73f Merge branch 'origin/main' into fix/auth-contract-mismatch
# Conflicts:
#	src/pages/Login.tsx
#	src/pages/Register.tsx
2026-03-30 13:12:32 +00:00
Barcode Betty 3a75ee7aee fix: align frontend auth with API token response contract
- Register sends display_name instead of name
- Register/Login handle TokenResponse (access_token, not token)
- Fetch /auth/me after register/login to populate user object

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 11:00:52 +00:00
cartsnitch-engineer[bot] 30d670a257 feat(ci): add auth image tag update to deploy-dev (#57)
Add build-and-push-auth job dependency and tag update to deploy-dev:
- build-and-push-auth: add outputs.calver_tag for downstream jobs
- deploy-dev: needs both build-and-push and build-and-push-auth
- deploy-dev: set auth image tag in dev overlay via kustomize

Refs: CAR-138

Co-authored-by: Barcode Betty <barcode-betty@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: cartsnitch-ceo[bot] <269712056+cartsnitch-ceo[bot]@users.noreply.github.com>
2026-03-30 09:59:41 +00:00
cpfarhood-k8s[bot] cfa4d8fa91 test 2026-03-30 00:50:51 +00:00
cartsnitch-engineer[bot] 39e8d5c9f9 fix(ci): install kustomize in deploy-dev job (#55)
* fix(ci): install kustomize in deploy-dev job

Add imranismail/setup-kustomize@v2 step so the deploy-dev job can
run kustomize edit set image without a "command not found" error.

Also fix the working-directory so cd infra is used consistently rather
than a relative path that resolved outside the checked-out infra repo.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): correct kustomize image name and tag in deploy-dev

- Remove '=' rename syntax which strips the GHCR registry prefix
- Use calver_tag output from build-and-push instead of github.sha
- Update commit message to reflect the correct tag

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): add path: infra to checkout step so cd infra succeeds

CTO review feedback: actions/checkout@v4 must specify path: infra
so that subsequent 'cd infra' commands resolve to the checked-out
infra repository, not the cartsnitch repo root.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): cd into overlay dir before kustomize edit set image

CTO review feedback: kustomize edit set image operates on the
kustomization.yaml in the current working directory. Since the
target file is at infra/apps/overlays/dev/kustomization.yaml, the
step must cd there before running kustomize.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Barcode Betty <noreply@paperclip.ing>
Co-authored-by: Stockboy Steve <stockboy-steve@paperclip.ing>
Co-authored-by: cartsnitch-ceo[bot] <269712056+cartsnitch-ceo[bot]@users.noreply.github.com>
2026-03-30 00:28:20 +00:00
cartsnitch-ceo[bot] 44c475265e Merge pull request #56 from cartsnitch/feat/uat-seed-user
feat: add dedicated UAT seed user with known credentials
2026-03-29 21:57:26 +00:00
cartsnitch-ceo[bot] 8e1f61214c Merge branch 'main' into feat/uat-seed-user 2026-03-29 21:54:43 +00:00
Barcode Betty 75be08ccf3 feat: add dedicated UAT seed user with known credentials
Add guaranteed UAT test user (uat@cartsnitch.com / CartSnitch-UAT-2026!)
seeded via Better-Auth bcrypt path. Idempotent — re-running the seed
skips the user if it already exists.

- Add 002_better_auth_tables Alembic migration (sessions, accounts,
  verifications tables + email_verified/image on users)
- Add bcrypt>=4.0,<6.0 to [seed] extra (CTO feedback: was bcrypt>=0.15,<1.0
  which matches zero installable versions)
- Fix account_id to use str(UAT_USER_ID) to match migration convention
  (CTO feedback: was using UAT_EMAIL which was inconsistent)
- Document credentials in common/README.md under Test Users

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 21:20:31 +00:00
12 changed files with 330 additions and 51 deletions
+12 -5
View File
@@ -111,6 +111,8 @@ jobs:
build-and-push-auth:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
@@ -161,7 +163,7 @@ jobs:
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push]
needs: [build-and-push, build-and-push-auth]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Generate GitHub App token
@@ -179,20 +181,25 @@ jobs:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update dev overlay image tag
working-directory: apps/overlays/dev
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd apps/overlays/dev
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add kustomization.yaml
git commit -m "ci(dev): update cartsnitch image to ${{ needs.build-and-push.outputs.calver_tag }}"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
git push origin main
+1 -45
View File
@@ -1,45 +1 @@
# CartSnitch Monorepo
CartSnitch is a self-hosted grocery price intelligence platform. This repo consolidates the core services and the flagship frontend PWA.
## Services
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | **Frontend** | React 18 PWA — mobile-first price intelligence UI |
| `api/` | **API Gateway** | FastAPI — frontend-facing REST API |
| `common/` | **Common** | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | **ReceiptWitness** | Purchase ingestion via retailer scrapers |
## Quick Start
### Frontend (root)
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build
npm run test # unit tests (Vitest)
```
### Python Services
Each Python service uses [uv](https://github.com/astral-sh/uv) and has its own `pyproject.toml`:
```bash
cd api # or common / receiptwitness
uv sync
uv run pytest
```
## Development Workflow
- **Never push directly to main.** Always open a PR from a feature branch.
- Branch naming: `feature/<description>` or `fix/<description>`
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
## Architecture
For full details see [CLAUDE.md](./CLAUDE.md) or the per-service `CLAUDE.md` in each subdirectory.
CartSnitch is a polyrepo-style monorepo: each service can be built and deployed independently, but sharing code between `common/` and the other Python services is done via local path dependencies in `pyproject.toml`.
# CartSnitch
+28
View File
@@ -0,0 +1,28 @@
# CartSnitch Common
Shared models, schemas, and utilities for CartSnitch services.
## Test Users
The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT.
| Email | Password | Display Name | Notes |
|---|---|---|---|
| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. |
### Running the Seed
```bash
# Install with seed dependencies
pip install -e "cartsnitch-common[seed]"
# Run (requires CARTSNITCH_DATABASE_URL_SYNC)
CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \
cartsnitch-seed
```
### Architecture
- **Models** live in `src/cartsnitch_common/models/`
- **Alembic migrations** run via the `api` service (`api/alembic/`)
- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point)
+1
View File
@@ -27,6 +27,7 @@ dev = [
]
seed = [
"faker>=33.0,<34.0",
"bcrypt>=4.0,<6.0",
]
[project.scripts]
@@ -2,8 +2,10 @@
import random
import time
import uuid
from typing import Any
import bcrypt
from faker import Faker
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -184,6 +186,65 @@ def run_seed(
session.commit()
_seed_uat_user(session)
elapsed = time.monotonic() - t0
_log("")
_log(f"Seed complete in {elapsed:.1f}s")
# ---------------------------------------------------------------------------
# UAT seed user
# ---------------------------------------------------------------------------
UAT_EMAIL = "uat@cartsnitch.com"
UAT_PASSWORD = "CartSnitch-UAT-2026!"
UAT_DISPLAY_NAME = "UAT Tester"
UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
def _seed_uat_user(session: Session) -> None:
"""Insert or verify the dedicated UAT test user.
The user is created via Better-Auth's bcrypt hashing path so credentials
work against the live auth service. Idempotent — skips if the user already
exists.
"""
existing = session.execute(
text("SELECT id FROM users WHERE email = :email"),
{"email": UAT_EMAIL},
).fetchone()
if existing is not None:
_log(f"UAT user {UAT_EMAIL} already exists — skipping")
return
password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode()
session.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())"
),
{
"id": str(UAT_USER_ID),
"email": UAT_EMAIL,
"hashed_password": password_hash,
"display_name": UAT_DISPLAY_NAME,
},
)
session.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": str(UAT_USER_ID),
"account_id": str(UAT_USER_ID),
"password": password_hash,
},
)
session.commit()
_log(f"UAT user {UAT_EMAIL} created")
+29 -1
View File
@@ -1,8 +1,36 @@
import { createAuthClient } from "better-auth/react"
import type { BetterFetchPlugin } from "@better-fetch/fetch"
/**
* Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema.
*/
const displayNameMapper: BetterFetchPlugin = {
id: "display-name-mapper",
name: "display-name-mapper",
hooks: {
onRequest: async (context) => {
const url = typeof context.url === "string" ? context.url : context.url.pathname
if (
url.endsWith("/auth/register") &&
context.method === "POST" &&
context.body &&
"name" in context.body
) {
context.body = {
...context.body,
display_name: context.body.name as string,
name: undefined,
}
}
return context
},
},
}
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
baseURL: import.meta.env.VITE_AUTH_URL || "",
basePath: "/auth",
fetchPlugins: [displayNameMapper],
})
export const { useSession, signIn, signUp, signOut } = authClient
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { formatCurrency } from '../formatCurrency';
describe('formatCurrency', () => {
it('formats 0 cents as $0.00', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('formats 199 cents as $1.99', () => {
expect(formatCurrency(199)).toBe('$1.99');
});
it('formats 10000 cents as $100.00', () => {
expect(formatCurrency(10000)).toBe('$100.00');
});
it('handles negative values', () => {
expect(formatCurrency(-500)).toBe('-$5.00');
});
it('handles large numbers', () => {
expect(formatCurrency(99999999)).toBe('$999,999.99');
});
it('supports custom locale', () => {
expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99');
});
it('supports custom currency', () => {
const result = formatCurrency(1000, 'en-US', 'EUR');
expect(result).toContain('10.00');
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { formatDate } from '../formatDate';
describe('formatDate', () => {
describe('short style', () => {
it('formats an ISO date string', () => {
const result = formatDate('2024-03-15', 'short');
expect(result).toMatch(/Mar 15, 2024/);
});
it('formats a Date object', () => {
const result = formatDate(new Date('2024-03-15'), 'short');
expect(result).toMatch(/Mar 15, 2024/);
});
});
describe('long style', () => {
it('formats with weekday and full month name', () => {
const result = formatDate('2024-03-15', 'long');
expect(result).toMatch(/Friday/);
expect(result).toMatch(/March/);
});
});
describe('relative style', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "just now" for very recent dates', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative');
expect(result).toBe('just now');
});
it('returns minutes ago', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative');
expect(result).toBe('15m ago');
});
it('returns hours ago', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative');
expect(result).toBe('3h ago');
});
it('returns days ago', () => {
const now = new Date('2024-01-05T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative');
expect(result).toBe('4d ago');
});
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs';
describe('storeSlugs', () => {
describe('STORE_SLUGS constant', () => {
it('contains meijer, kroger, and target', () => {
expect(STORE_SLUGS).toHaveProperty('meijer');
expect(STORE_SLUGS).toHaveProperty('kroger');
expect(STORE_SLUGS).toHaveProperty('target');
});
});
describe('getStore', () => {
it('returns store data for known slug', () => {
const store = getStore('meijer');
expect(store).toEqual({
name: 'Meijer',
color: '#e31837',
icon: '/icons/stores/meijer.svg',
});
});
it('returns null for unknown slug', () => {
expect(getStore('unknown-store')).toBeNull();
});
it('is case insensitive', () => {
expect(getStore('KROGER')).toBeTruthy();
expect(getStore('Target')).toBeTruthy();
});
});
describe('getStoreName', () => {
it('returns store name for known slug', () => {
expect(getStoreName('kroger')).toBe('Kroger');
});
it('returns raw slug for unknown store', () => {
expect(getStoreName('unknown-store')).toBe('unknown-store');
});
it('is case insensitive', () => {
expect(getStoreName('TARGET')).toBe('Target');
});
});
});
+10
View File
@@ -0,0 +1,10 @@
export function formatCurrency(
cents: number,
locale = 'en-US',
currency = 'USD'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(cents / 100);
}
+34
View File
@@ -0,0 +1,34 @@
export function formatDate(
date: string | Date,
style: 'short' | 'long' | 'relative' = 'short'
): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (style === 'short') {
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
if (style === 'long') {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
// relative
const diff = Date.now() - d.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
+13
View File
@@ -0,0 +1,13 @@
export const STORE_SLUGS: Record<string, { name: string; color: string; icon: string }> = {
meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' },
kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' },
target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' },
};
export function getStore(slug: string) {
return STORE_SLUGS[slug.toLowerCase()] ?? null;
}
export function getStoreName(slug: string): string {
return getStore(slug)?.name ?? slug;
}