sync(api): copy latest standalone code and merge alembic migrations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Barcode Betty
2026-04-03 07:54:31 +00:00
parent 3a2231921a
commit b52fae5894
32 changed files with 717 additions and 498 deletions
+19 -85
View File
@@ -1,100 +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 uuid import UUID
from datetime import UTC, datetime
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi import Depends, Header, HTTPException, status
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.database import get_db
# Keep Bearer scheme as optional — Better-Auth primarily uses cookies,
# but we support Bearer tokens for service-to-service or mobile clients.
bearer_scheme = HTTPBearer(auto_error=False)
# Better-Auth session cookie names.
# Over HTTPS Better-Auth adds the __Secure- prefix automatically.
SESSION_COOKIE_NAMES = [
"__Secure-better-auth.session_token", # HTTPS (deployed)
"better-auth.session_token", # HTTP (local dev)
]
async def _validate_session_token(token: str, db: AsyncSession) -> str:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as str) if the session is valid and not expired.
Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie
is signed: ``rawToken.base64HMACSignature``. Strip the signature
before querying.
"""
# Signed cookie format: rawToken.hmacSignature — split and use only the token part
raw_token = token.split(".")[0] if "." in token else token
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": raw_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 str(user_id)
bearer_scheme = HTTPBearer()
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> str:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
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 (try both names for HTTP/HTTPS compatibility)
cookie_token = None
for name in SESSION_COOKIE_NAMES:
cookie_token = request.cookies.get(name)
if cookie_token:
break
if cookie_token:
token = cookie_token
# 2. Fall back to Bearer header
if not token and credentials:
token = credentials.credentials
if not token:
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> UUID:
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(
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:
+5 -4
View File
@@ -2,21 +2,22 @@
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from uuid import UUID
from jose import JWTError, jwt
from cartsnitch_api.config import settings
def create_access_token(user_id: str) -> str:
def create_access_token(user_id: UUID) -> str:
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
payload = {"sub": user_id, "exp": expire, "type": "access"}
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
def create_refresh_token(user_id: str) -> str:
def create_refresh_token(user_id: UUID) -> str:
expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
payload = {"sub": user_id, "exp": expire, "type": "refresh"}
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
+68 -8
View File
@@ -1,16 +1,20 @@
"""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 fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.models import User
from cartsnitch_api.schemas import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UpdateUserRequest,
UserResponse,
)
@@ -19,9 +23,40 @@ from cartsnitch_api.services.auth import AuthService
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)
async def get_me(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -36,7 +71,7 @@ async def get_me(
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -52,7 +87,7 @@ async def update_me(
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -62,3 +97,28 @@ async def delete_me(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
async def get_email_in_address(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User.email_inbound_token).where(User.id == user_id))
token = result.scalar_one_or_none()
if not token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
) from None
return EmailInAddressResponse(
email_address=f"receipts+{token}@receipts.cartsnitch.com",
instructions=(
"Forward your digital receipt emails to this address. "
"We currently support Meijer, Kroger, and Target receipt emails."
),
)
-2
View File
@@ -19,8 +19,6 @@ class Settings(BaseSettings):
# Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
auth_service_url: str = "http://auth:3001"
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
receiptwitness_url: str = "http://receiptwitness:8001"
+10 -14
View File
@@ -2,7 +2,7 @@
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from fastapi import FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.middleware.cors import add_cors_middleware
@@ -46,19 +46,15 @@ def create_app() -> FastAPI:
# Routers
app.include_router(health_router)
app.include_router(auth_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)
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)
return app
+2 -2
View File
@@ -9,14 +9,14 @@ from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import DiscountType
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.store import Store
class Coupon(UUIDPrimaryKeyMixin, Base):
class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A coupon or deal for a product at a store."""
__tablename__ = "coupons"
+2 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import PriceSource
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store
class PriceHistory(UUIDPrimaryKeyMixin, Base):
class PriceHistory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A single price observation for a product at a store on a date."""
__tablename__ = "price_history"
+4 -4
View File
@@ -18,7 +18,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.price import PriceHistory
@@ -27,12 +27,12 @@ if TYPE_CHECKING:
from cartsnitch_api.models.user import User
class Purchase(UUIDPrimaryKeyMixin, Base):
class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A single shopping trip / receipt."""
__tablename__ = "purchases"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[uuid.UUID] = 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)
@@ -61,7 +61,7 @@ class Purchase(UUIDPrimaryKeyMixin, Base):
)
class PurchaseItem(UUIDPrimaryKeyMixin, Base):
class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Individual line item on a receipt."""
__tablename__ = "purchase_items"
+2 -2
View File
@@ -9,13 +9,13 @@ from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import SizeUnit
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
class ShrinkflationEvent(UUIDPrimaryKeyMixin, Base):
class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Detected shrinkflation event — product size changed while price held or rose."""
__tablename__ = "shrinkflation_events"
+10 -4
View File
@@ -1,10 +1,11 @@
"""User and UserStoreAccount models."""
import secrets
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus
@@ -16,15 +17,20 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store
class User(TimestampMixin, Base):
class User(UUIDPrimaryKeyMixin, 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))
email_inbound_token: Mapped[str] = mapped_column(
String(22),
nullable=False,
unique=True,
default=lambda: secrets.token_urlsafe(16),
)
# Relationships
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user")
@@ -37,7 +43,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[str] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[uuid.UUID] = 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))
+5 -3
View File
@@ -1,5 +1,7 @@
"""Alert routes: list alerts, manage settings."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +15,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"])
@router.get("", response_model=list[AlertResponse])
async def list_alerts(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
@@ -22,7 +24,7 @@ async def list_alerts(
@router.get("/settings", response_model=AlertSettingsResponse)
async def get_alert_settings(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
@@ -32,7 +34,7 @@ async def get_alert_settings(
@router.put("/settings")
async def update_alert_settings(
body: AlertSettingsRequest,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(
+2 -2
View File
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"])
@router.get("", response_model=list[CouponResponse])
async def list_coupons(
store_id: UUID | None = Query(None),
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
@@ -25,7 +25,7 @@ async def list_coupons(
@router.get("/relevant", response_model=list[CouponResponse])
async def relevant_coupons(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
+3 -3
View File
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"])
@router.get("/trends", response_model=list[PriceTrendResponse])
async def price_trends(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
category: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
@@ -30,7 +30,7 @@ async def price_trends(
@router.get("/increases", response_model=list[PriceIncreaseResponse])
async def price_increases(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
@@ -40,7 +40,7 @@ async def price_increases(
@router.get("/comparison", response_model=list[PriceComparisonResponse])
async def price_comparison(
product_ids: Annotated[list[UUID], Query()],
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductResponse])
async def list_products(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
q: str | None = Query(None),
category: str | None = Query(None),
page: int = Query(1, ge=1),
@@ -29,7 +29,7 @@ async def list_products(
@router.get("/{product_id}", response_model=ProductDetailResponse)
async def get_product(
product_id: UUID,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
@@ -44,7 +44,7 @@ async def get_product(
@router.get("/{product_id}/prices", response_model=PriceTrendResponse)
async def get_product_prices(
product_id: UUID,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"])
@router.get("", response_model=list[PurchaseResponse])
async def list_purchases(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
store_id: UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
@@ -27,7 +27,7 @@ async def list_purchases(
@router.get("/stats", response_model=PurchaseStatsResponse)
async def purchase_stats(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
@@ -37,7 +37,7 @@ async def purchase_stats(
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
async def get_purchase(
purchase_id: UUID,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
+4 -2
View File
@@ -1,5 +1,7 @@
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
@@ -11,7 +13,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"])
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)):
async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
result = await client.trigger_sync(str(user_id), store_slug)
@@ -29,7 +31,7 @@ async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)
@router.get("/status", response_model=list[SyncStatusResponse])
async def sync_status(user_id: str = Depends(get_current_user)):
async def sync_status(user_id: UUID = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
return await client.get_sync_status(str(user_id))
+4 -2
View File
@@ -1,5 +1,7 @@
"""Shopping routes: optimize list, saved lists."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
@@ -11,7 +13,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.post("/optimize", response_model=OptimizeResponse)
async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)):
async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)):
client = ClipArtistClient()
try:
result = await client.optimize(
@@ -35,7 +37,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_cu
@router.get("/lists", response_model=list[ShoppingListResponse])
async def list_shopping_lists(user_id: str = Depends(get_current_user)):
async def list_shopping_lists(user_id: UUID = Depends(get_current_user)):
client = ClipArtistClient()
try:
return await client.get_shopping_lists(str(user_id))
+5 -3
View File
@@ -1,5 +1,7 @@
"""Store routes: list stores, manage user store connections."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,7 +21,7 @@ async def list_stores(db: AsyncSession = Depends(get_db)):
@router.get("/me/stores", response_model=list[StoreAccountResponse])
async def list_user_stores(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
@@ -34,7 +36,7 @@ async def list_user_stores(
async def connect_store(
store_slug: str,
body: ConnectStoreRequest,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
@@ -49,7 +51,7 @@ async def connect_store(
@router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect_store(
store_slug: str,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
+26 -6
View File
@@ -1,13 +1,33 @@
"""Pydantic v2 request/response schemas for all API endpoints."""
from datetime import date, datetime
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# ---------- 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):
@@ -16,7 +36,7 @@ class UpdateUserRequest(BaseModel):
class UserResponse(BaseModel):
id: str
id: UUID
email: str
display_name: str
created_at: datetime
@@ -60,7 +80,7 @@ class PurchaseResponse(BaseModel):
id: UUID
store_id: UUID
store_name: str
purchased_at: date
purchased_at: datetime
total: float
item_count: int
@@ -142,7 +162,7 @@ class CouponResponse(BaseModel):
discount_value: float
discount_type: str
product_id: UUID | None = None
expires_at: date | None = None
expires_at: datetime | None = None
# ---------- Shopping ----------
+5 -3
View File
@@ -4,6 +4,8 @@ Alerts are generated by StickerShock and ShrinkRay services and written to the D
This service reads them for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -13,7 +15,7 @@ class AlertService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_alerts(self, user_id: str) -> list[dict]:
async def list_alerts(self, user_id: UUID) -> list[dict]:
"""List shrinkflation events for products the user has purchased."""
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
@@ -55,7 +57,7 @@ class AlertService:
for e in events
]
async def get_settings(self, user_id: str) -> dict:
async def get_settings(self, user_id: UUID) -> dict:
# Alert settings would be stored in a user_settings table.
# For now, return defaults since the table doesn't exist yet in common lib.
return {
@@ -64,7 +66,7 @@ class AlertService:
"email_notifications": False,
}
async def update_settings(self, user_id: str, **fields) -> dict:
async def update_settings(self, user_id: UUID, **fields) -> dict:
# Would update user_settings table. Return merged defaults for now.
current = await self.get_settings(user_id)
for k, v in fields.items():
+65 -8
View File
@@ -1,19 +1,68 @@
"""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 sqlalchemy import select
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:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_user(self, user_id: str) -> dict:
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:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -28,7 +77,7 @@ class AuthService:
"created_at": user.created_at,
}
async def update_user(self, user_id: str, **fields) -> dict:
async def update_user(self, user_id: UUID, **fields) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -56,7 +105,7 @@ class AuthService:
"created_at": user.created_at,
}
async def delete_user(self, user_id: str) -> None:
async def delete_user(self, user_id: UUID) -> None:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -66,3 +115,11 @@ class AuthService:
await self.db.delete(user)
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,
}
+1 -1
View File
@@ -29,7 +29,7 @@ class CouponService:
coupons = result.scalars().all()
return [self._to_dict(c) for c in coupons]
async def relevant_coupons(self, user_id: str) -> list[dict]:
async def relevant_coupons(self, user_id: UUID) -> list[dict]:
"""Coupons for products the user has purchased."""
from cartsnitch_api.models import Coupon, PurchaseItem
+3 -3
View File
@@ -13,7 +13,7 @@ class PurchaseService:
async def list_purchases(
self,
user_id: str,
user_id: UUID,
store_id: UUID | None = None,
page: int = 1,
page_size: int = 20,
@@ -56,7 +56,7 @@ class PurchaseService:
for p, item_count, store_name in result.all()
]
async def get_purchase(self, purchase_id: UUID, user_id: str) -> dict:
async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
@@ -88,7 +88,7 @@ class PurchaseService:
],
}
async def get_stats(self, user_id: str) -> dict:
async def get_stats(self, user_id: UUID) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
+4 -3
View File
@@ -1,6 +1,7 @@
"""Store service — list stores, manage user store account connections."""
import json
from uuid import UUID
from cryptography.fernet import Fernet
from sqlalchemy import select
@@ -34,7 +35,7 @@ class StoreService:
for s in stores
]
async def list_user_stores(self, user_id: str) -> list[dict]:
async def list_user_stores(self, user_id: UUID) -> list[dict]:
from cartsnitch_api.models import UserStoreAccount
result = await self.db.execute(
@@ -59,7 +60,7 @@ class StoreService:
for a in accounts
]
async def connect_store(self, user_id: str, store_slug: str, credentials: dict | None) -> dict:
async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
@@ -106,7 +107,7 @@ class StoreService:
"sync_status": "active",
}
async def disconnect_store(self, user_id: str, store_slug: str) -> None:
async def disconnect_store(self, user_id: UUID, store_slug: str) -> None:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))