Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 440f92e96e | |||
| 97bbdf68a5 | |||
| 02e5bee390 | |||
| d475b3876a | |||
| 76bcc53992 | |||
| 470b615528 | |||
| f26f8f7e56 | |||
| 78b7831d43 | |||
| e45b510519 | |||
| f25044ea7e | |||
| b637fd9c11 | |||
| 983ee2c398 | |||
| 8af7b37b38 | |||
| b21a30b2e7 | |||
| 361ad3acc2 | |||
| 5e165d277e | |||
| 6828e4d0a9 | |||
| 00b2b2469b |
@@ -95,7 +95,7 @@ jobs:
|
||||
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
|
||||
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
|
||||
|
||||
build-and-push:
|
||||
runs-on: runners-cartsnitch
|
||||
|
||||
+1
-1
@@ -30,4 +30,4 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
|
||||
|
||||
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
|
||||
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
|
||||
a new user, Postgres throws:
|
||||
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
|
||||
|
||||
The sessions, accounts, and verifications tables already use text IDs — only users,
|
||||
user_store_accounts.user_id, and purchases.user_id needed fixing.
|
||||
|
||||
Revision ID: 004_fix_user_id_text
|
||||
Revises: 003_make_users_hashed_password_nullable
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "004_fix_user_id_text"
|
||||
down_revision = "003_make_users_hashed_password_nullable"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Step 1: Drop existing FK constraints
|
||||
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",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.UUID(),
|
||||
postgresql_using="id::text",
|
||||
)
|
||||
|
||||
# Step 3: Alter user_store_accounts.user_id from uuid to text
|
||||
op.alter_column(
|
||||
"user_store_accounts",
|
||||
"user_id",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.UUID(),
|
||||
postgresql_using="user_id::text",
|
||||
)
|
||||
|
||||
# Step 4: Alter purchases.user_id from uuid to text
|
||||
op.alter_column(
|
||||
"purchases",
|
||||
"user_id",
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.UUID(),
|
||||
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) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE purchases "
|
||||
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop FK constraints
|
||||
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"))
|
||||
|
||||
# Revert users.id from text to uuid
|
||||
op.alter_column(
|
||||
"users",
|
||||
"id",
|
||||
type_=sa.UUID(),
|
||||
existing_type=sa.Text(),
|
||||
postgresql_using="id::uuid",
|
||||
)
|
||||
|
||||
# Revert user_store_accounts.user_id from text to uuid
|
||||
op.alter_column(
|
||||
"user_store_accounts",
|
||||
"user_id",
|
||||
type_=sa.UUID(),
|
||||
existing_type=sa.Text(),
|
||||
postgresql_using="user_id::uuid",
|
||||
)
|
||||
|
||||
# Revert purchases.user_id from text to uuid
|
||||
op.alter_column(
|
||||
"purchases",
|
||||
"user_id",
|
||||
type_=sa.UUID(),
|
||||
existing_type=sa.Text(),
|
||||
postgresql_using="user_id::uuid",
|
||||
)
|
||||
|
||||
# Re-add FK constraints (PostgreSQL will auto-name them)
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE user_store_accounts "
|
||||
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
text(
|
||||
"ALTER TABLE purchases "
|
||||
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
@@ -5,6 +5,7 @@ Sessions are verified by querying the shared sessions table directly.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
||||
@@ -27,10 +28,13 @@ 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.
|
||||
Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the
|
||||
incoming raw token before querying.
|
||||
"""
|
||||
hashed_token = sha256(token.encode("utf-8")).hexdigest()
|
||||
result = await db.execute(
|
||||
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
||||
{"token": token},
|
||||
{"token": hashed_token},
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from cartsnitch_api.auth.routes import router as auth_router
|
||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||
@@ -46,15 +46,19 @@ def create_app() -> FastAPI:
|
||||
# Routers
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(stores_router)
|
||||
app.include_router(purchases_router)
|
||||
app.include_router(products_router)
|
||||
app.include_router(prices_router)
|
||||
app.include_router(coupons_router)
|
||||
app.include_router(shopping_router)
|
||||
app.include_router(alerts_router)
|
||||
app.include_router(scraping_router)
|
||||
app.include_router(public_router)
|
||||
|
||||
# Data endpoints mounted under /api/v1
|
||||
v1_router = APIRouter(prefix="/api/v1")
|
||||
v1_router.include_router(stores_router)
|
||||
v1_router.include_router(purchases_router)
|
||||
v1_router.include_router(products_router)
|
||||
v1_router.include_router(prices_router)
|
||||
v1_router.include_router(coupons_router)
|
||||
v1_router.include_router(shopping_router)
|
||||
v1_router.include_router(alerts_router)
|
||||
v1_router.include_router(scraping_router)
|
||||
v1_router.include_router(public_router)
|
||||
app.include_router(v1_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import AccountStatus
|
||||
@@ -16,11 +16,12 @@ if TYPE_CHECKING:
|
||||
from cartsnitch_api.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))
|
||||
@@ -36,7 +37,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)
|
||||
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
|
||||
+7
-2
@@ -3,7 +3,12 @@
|
||||
"collect": {
|
||||
"staticDistDir": "./dist",
|
||||
"url": ["http://localhost:4173/"],
|
||||
"numberOfRuns": 1
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
"skipAudits": ["bf-cache"],
|
||||
"disableFullPageScreenshot": true
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
@@ -16,4 +21,4 @@
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
|
||||
export function usePriceHistory(productId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['priceHistory', productId],
|
||||
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
|
||||
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
|
||||
enabled: !!productId,
|
||||
})
|
||||
}
|
||||
@@ -50,6 +50,6 @@ export function useCoupons() {
|
||||
export function usePriceAlerts() {
|
||||
return useQuery({
|
||||
queryKey: ['priceAlerts'],
|
||||
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
|
||||
queryFn: () => api.get<PriceAlert[]>('/alerts'),
|
||||
})
|
||||
}
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
|
||||
'/purchases': () => mockPurchases,
|
||||
'/products': () => mockProducts,
|
||||
'/coupons': () => mockCoupons,
|
||||
'/price-alerts': () => mockAlerts,
|
||||
'/alerts': () => mockAlerts,
|
||||
}
|
||||
|
||||
function matchMockRoute<T>(path: string): T | null {
|
||||
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
|
||||
}
|
||||
|
||||
// /products/:id/price-history
|
||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
|
||||
if (priceHistoryMatch) {
|
||||
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
||||
}
|
||||
|
||||
+8
-2
@@ -31,8 +31,14 @@ export function Login() {
|
||||
throw new Error(authError.message ?? 'Sign in failed')
|
||||
}
|
||||
|
||||
setAuthenticated(true)
|
||||
navigate('/')
|
||||
// After successful signIn, force a session fetch to confirm the cookie is set
|
||||
// before navigating to the protected route
|
||||
const sessionResult = await authClient.getSession()
|
||||
if (sessionResult.data) {
|
||||
navigate('/')
|
||||
} else {
|
||||
setError('Sign in failed. Please try again.')
|
||||
}
|
||||
} catch {
|
||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||
setAuthenticated(true)
|
||||
|
||||
@@ -38,8 +38,15 @@ export function Register() {
|
||||
throw new Error(authError.message ?? 'Registration failed')
|
||||
}
|
||||
|
||||
setAuthenticated(true)
|
||||
navigate('/')
|
||||
// After successful signUp, force a session fetch to confirm the cookie is set
|
||||
// before navigating to the protected route
|
||||
const sessionResult = await authClient.getSession()
|
||||
if (sessionResult.data) {
|
||||
navigate('/')
|
||||
} else {
|
||||
// Session not established — show success message and link to login
|
||||
setError('Account created! Please sign in.')
|
||||
}
|
||||
} catch {
|
||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||
setAuthenticated(true)
|
||||
|
||||
@@ -61,5 +61,5 @@ export const handlers = [
|
||||
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
||||
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
||||
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
||||
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
|
||||
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user