forked from cartsnitch/cartsnitch
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b6b9e7d01 | |||
| 835aff3522 | |||
| 5588c1b5d8 | |||
| c5ed863ab1 | |||
| 8d0552f73f | |||
| 3a75ee7aee | |||
| 30d670a257 | |||
| cfa4d8fa91 | |||
| 39e8d5c9f9 | |||
| 44c475265e | |||
| 8e1f61214c | |||
| fb1c5fb929 | |||
| 75be08ccf3 |
@@ -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 +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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user