Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2b0e7cbd3 | |||
| 6d7d54729c | |||
| 895ad77850 | |||
| 5f1570e6d2 | |||
| 96c0f89a03 | |||
| 0f8aa2fe47 | |||
| 6903c7dde3 | |||
| 2946ac8dc5 | |||
| 6717e105f4 | |||
| 01ea36c5aa | |||
| 0bb4b7d183 | |||
| 7b9194a152 | |||
| ca8cf2a80a | |||
| 5bb0a5817b | |||
| 2444219f75 | |||
| c707caea41 |
@@ -46,6 +46,31 @@ jobs:
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
lighthouse:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Install Chromium for Lighthouse
|
||||
run: |
|
||||
npm install -g playwright
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Start preview server
|
||||
run: |
|
||||
npm run preview &
|
||||
npx wait-on http://localhost:4173/ --timeout 30000
|
||||
- name: Run Lighthouse CI
|
||||
run: |
|
||||
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
|
||||
npm install -g @lhci/cli
|
||||
LHCI_CHROME_PATH="$CHROME_PATH" lhci autorun
|
||||
|
||||
build-and-push:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [lint, test]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Change users.id and FK columns from uuid to text.
|
||||
|
||||
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
|
||||
but the users table was using PostgreSQL uuid type, causing INSERT failures.
|
||||
|
||||
Revision ID: 003_fix_user_id_text
|
||||
Revises: 002_better_auth_tables
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "003_fix_user_id_text"
|
||||
down_revision = "002_better_auth_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Step 1: Drop FK constraints that reference users.id
|
||||
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||
|
||||
# Step 2: Alter users.id from uuid to text
|
||||
op.alter_column("users", "id", existing_type=sa.UUID(), type_=sa.Text(), existing_nullable=False, postgresql_using="id::text")
|
||||
|
||||
# Step 3: Alter user_store_accounts.user_id from uuid to text
|
||||
op.alter_column("user_store_accounts", "user_id", existing_type=sa.UUID(), type_=sa.Text(), existing_nullable=False, postgresql_using="user_id::text")
|
||||
|
||||
# Step 4: Alter purchases.user_id from uuid to text
|
||||
op.alter_column("purchases", "user_id", existing_type=sa.UUID(), type_=sa.Text(), existing_nullable=False, postgresql_using="user_id::text")
|
||||
|
||||
# Step 5: Re-add FK constraints
|
||||
op.execute(text("ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
|
||||
op.execute(text("ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop FK constraints
|
||||
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||
|
||||
# Alter back to UUID
|
||||
op.alter_column("purchases", "user_id", existing_type=sa.Text(), type_=sa.UUID(), existing_nullable=False, postgresql_using="user_id::uuid")
|
||||
op.alter_column("user_store_accounts", "user_id", existing_type=sa.Text(), type_=sa.UUID(), existing_nullable=False, postgresql_using="user_id::uuid")
|
||||
op.alter_column("users", "id", existing_type=sa.Text(), type_=sa.UUID(), existing_nullable=False, postgresql_using="id::uuid")
|
||||
|
||||
# Re-add FK constraints
|
||||
op.execute(text("ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
|
||||
op.execute(text("ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id)"))
|
||||
@@ -36,6 +36,15 @@ export const auth = betterAuth({
|
||||
},
|
||||
|
||||
session: {
|
||||
modelName: "sessions",
|
||||
fields: {
|
||||
userId: "user_id",
|
||||
expiresAt: "expires_at",
|
||||
ipAddress: "ip_address",
|
||||
userAgent: "user_agent",
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
},
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // refresh after 1 day
|
||||
cookieCache: {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Change users.id and user_id FKs from uuid to text.
|
||||
|
||||
Revision ID: 003_fix_user_id_text
|
||||
Revises:
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "003_fix_user_id_text"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop FK constraints first
|
||||
op.execute("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")
|
||||
op.execute("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")
|
||||
|
||||
# Alter users.id from uuid to text
|
||||
op.execute("ALTER TABLE users ALTER COLUMN id TYPE text USING id::text")
|
||||
|
||||
# Alter user_id columns from uuid to text
|
||||
op.execute("ALTER TABLE user_store_accounts ALTER COLUMN user_id TYPE text USING user_id::text")
|
||||
op.execute("ALTER TABLE purchases ALTER COLUMN user_id TYPE text USING user_id::text")
|
||||
|
||||
# Re-add FK constraints
|
||||
op.execute(
|
||||
"ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop FK constraints
|
||||
op.execute("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")
|
||||
op.execute("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")
|
||||
|
||||
# Alter back to uuid
|
||||
op.execute("ALTER TABLE users ALTER COLUMN id TYPE uuid USING id::uuid")
|
||||
op.execute("ALTER TABLE user_store_accounts ALTER COLUMN user_id TYPE uuid USING user_id::uuid")
|
||||
op.execute("ALTER TABLE purchases ALTER COLUMN user_id TYPE uuid USING user_id::uuid")
|
||||
|
||||
# Re-add FK constraints
|
||||
op.execute(
|
||||
"ALTER TABLE user_store_accounts ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id)"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE purchases ADD CONSTRAINT purchases_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id)"
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
|
||||
__tablename__ = "purchases"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
||||
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
|
||||
@@ -15,11 +15,12 @@ if TYPE_CHECKING:
|
||||
from cartsnitch_common.models.store import Store
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
class User(TimestampMixin, Base):
|
||||
"""Application user."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||
@@ -37,7 +38,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "user_store_accounts"
|
||||
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
# WARNING: Contains retailer session cookies/tokens. Encryption-at-rest
|
||||
# required before production deployment (e.g., pgcrypto or app-level encryption).
|
||||
|
||||
@@ -40,7 +40,7 @@ class PurchaseItemRead(BaseModel):
|
||||
|
||||
|
||||
class PurchaseCreate(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
user_id: str
|
||||
store_id: uuid.UUID
|
||||
store_location_id: uuid.UUID | None = None
|
||||
receipt_id: str
|
||||
@@ -58,7 +58,7 @@ class PurchaseRead(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
user_id: str
|
||||
store_id: uuid.UUID
|
||||
store_location_id: uuid.UUID | None
|
||||
receipt_id: str
|
||||
|
||||
@@ -17,7 +17,7 @@ class UserCreate(BaseModel):
|
||||
class UserRead(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
id: str
|
||||
email: str
|
||||
display_name: str | None
|
||||
created_at: datetime
|
||||
@@ -25,7 +25,7 @@ class UserRead(BaseModel):
|
||||
|
||||
|
||||
class UserStoreAccountCreate(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
user_id: str
|
||||
store_id: uuid.UUID
|
||||
session_data: dict | None = None
|
||||
status: AccountStatus = AccountStatus.ACTIVE
|
||||
@@ -35,7 +35,7 @@ class UserStoreAccountRead(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
user_id: str
|
||||
store_id: uuid.UUID
|
||||
status: AccountStatus
|
||||
session_expires_at: datetime | None
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"staticDistDir": "./dist",
|
||||
"url": ["http://localhost:4173/"],
|
||||
"numberOfRuns": 1
|
||||
},
|
||||
"assert": {
|
||||
"preset": "lighthouse:no-pwa",
|
||||
"assertions": {
|
||||
"categories:performance": ["warn", { "minScore": 0.5 }],
|
||||
"categories:accessibility": ["error", { "minScore": 0.7 }],
|
||||
"categories:best-practices": ["warn", { "minScore": 0.8 }],
|
||||
"categories:seo": ["warn", { "minScore": 0.7 }],
|
||||
"errors-in-console": ["warn"],
|
||||
"network-dependency-tree-insight": ["warn"],
|
||||
"robots-txt": ["warn"],
|
||||
"unused-javascript": ["warn"]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://cartsnitch.com/sitemap.xml
|
||||
@@ -1,36 +1,8 @@
|
||||
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 || "",
|
||||
basePath: "/auth",
|
||||
fetchPlugins: [displayNameMapper],
|
||||
})
|
||||
|
||||
export const { useSession, signIn, signUp, signOut } = authClient
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export function formatCurrency(
|
||||
cents: number,
|
||||
locale = 'en-US',
|
||||
currency = 'USD'
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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`;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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