forked from cartsnitch/api
feat: merge cartsnitch/api into api/ subdirectory
Consolidate API gateway service into monorepo. Squashed from https://github.com/cartsnitch/api main (89bacb1). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Alert service — price and shrinkflation alerts for users.
|
||||
|
||||
Alerts are generated by StickerShock and ShrinkRay services and written to the DB.
|
||||
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
|
||||
|
||||
|
||||
class AlertService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
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
|
||||
|
||||
# Get product IDs from user's purchases
|
||||
items_result = await self.db.execute(
|
||||
select(PurchaseItem.normalized_product_id)
|
||||
.join(Purchase)
|
||||
.where(
|
||||
Purchase.user_id == user_id,
|
||||
PurchaseItem.normalized_product_id.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
product_ids = [row[0] for row in items_result.all()]
|
||||
|
||||
if not product_ids:
|
||||
return []
|
||||
|
||||
result = await self.db.execute(
|
||||
select(ShrinkflationEvent)
|
||||
.where(ShrinkflationEvent.normalized_product_id.in_(product_ids))
|
||||
.options(selectinload(ShrinkflationEvent.normalized_product))
|
||||
.order_by(ShrinkflationEvent.detected_date.desc())
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"alert_type": "shrinkflation",
|
||||
"product_id": e.normalized_product_id,
|
||||
"product_name": e.normalized_product.canonical_name,
|
||||
"message": (
|
||||
f"Size changed from {e.old_size}{e.old_unit} to {e.new_size}{e.new_unit}"
|
||||
),
|
||||
"triggered_at": e.detected_date,
|
||||
"read": False,
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
|
||||
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 {
|
||||
"price_increase_threshold_pct": 5.0,
|
||||
"shrinkflation_enabled": True,
|
||||
"email_notifications": False,
|
||||
}
|
||||
|
||||
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():
|
||||
if v is not None and k in current:
|
||||
current[k] = v
|
||||
return current
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Auth service — user registration, login, token management."""
|
||||
|
||||
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 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))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise LookupError("User not found")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
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))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise LookupError("User not found")
|
||||
|
||||
if "display_name" in fields and fields["display_name"] is not None:
|
||||
user.display_name = fields["display_name"]
|
||||
if "email" in fields and fields["email"] is not None:
|
||||
existing = await self.db.execute(
|
||||
select(User).where(User.email == fields["email"], User.id != user_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError("Email already in use")
|
||||
user.email = fields["email"]
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
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))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise LookupError("User not found")
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""HTTP client for ClipArtist internal API."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class ClipArtistClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.clipartist_url
|
||||
self.headers = {"X-Service-Key": settings.service_key}
|
||||
|
||||
async def optimize(
|
||||
self,
|
||||
user_id: str,
|
||||
items: list[dict],
|
||||
preferred_stores: list[str] | None = None,
|
||||
) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/optimize",
|
||||
headers=self.headers,
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"items": items,
|
||||
"preferred_stores": preferred_stores,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], resp.json())
|
||||
|
||||
async def get_shopping_lists(self, user_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/shopping-lists",
|
||||
headers=self.headers,
|
||||
params={"user_id": user_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(list[dict[str, Any]], resp.json())
|
||||
|
||||
async def get_relevant_coupons(self, user_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/coupons/relevant",
|
||||
headers=self.headers,
|
||||
params={"user_id": user_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(list[dict[str, Any]], resp.json())
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Coupon service — browse coupons, find relevant ones."""
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
||||
class CouponService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_coupons(self, store_id: UUID | None = None) -> list[dict]:
|
||||
from cartsnitch_api.models import Coupon
|
||||
|
||||
today = date.today()
|
||||
query = (
|
||||
select(Coupon)
|
||||
.where((Coupon.valid_to >= today) | (Coupon.valid_to.is_(None)))
|
||||
.options(selectinload(Coupon.store))
|
||||
.order_by(Coupon.valid_to.asc().nullslast())
|
||||
)
|
||||
if store_id:
|
||||
query = query.where(Coupon.store_id == store_id)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
coupons = result.scalars().all()
|
||||
return [self._to_dict(c) for c in coupons]
|
||||
|
||||
async def relevant_coupons(self, user_id: UUID) -> list[dict]:
|
||||
"""Coupons for products the user has purchased."""
|
||||
from cartsnitch_api.models import Coupon, PurchaseItem
|
||||
|
||||
today = date.today()
|
||||
|
||||
# Get product IDs from user's purchase history
|
||||
from cartsnitch_api.models import Purchase
|
||||
|
||||
items_result = await self.db.execute(
|
||||
select(PurchaseItem.normalized_product_id)
|
||||
.join(Purchase)
|
||||
.where(
|
||||
Purchase.user_id == user_id,
|
||||
PurchaseItem.normalized_product_id.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
product_ids = [row[0] for row in items_result.all()]
|
||||
|
||||
if not product_ids:
|
||||
return []
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Coupon)
|
||||
.where(
|
||||
Coupon.normalized_product_id.in_(product_ids),
|
||||
(Coupon.valid_to >= today) | (Coupon.valid_to.is_(None)),
|
||||
)
|
||||
.options(selectinload(Coupon.store))
|
||||
)
|
||||
coupons = result.scalars().all()
|
||||
return [self._to_dict(c) for c in coupons]
|
||||
|
||||
def _to_dict(self, c) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"store_id": c.store_id,
|
||||
"store_name": c.store.name,
|
||||
"description": c.description or c.title,
|
||||
"discount_value": float(c.discount_value) if c.discount_value else 0,
|
||||
"discount_type": c.discount_type,
|
||||
"product_id": c.normalized_product_id,
|
||||
"expires_at": c.valid_to,
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Price service — trends, increases, comparison."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from cartsnitch_api.services.queries import latest_price_per_store
|
||||
|
||||
|
||||
class PriceService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_trends(self, category: str | None = None) -> list[dict]:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
query = (
|
||||
select(PriceHistory)
|
||||
.join(NormalizedProduct)
|
||||
.options(
|
||||
selectinload(PriceHistory.store),
|
||||
selectinload(PriceHistory.normalized_product),
|
||||
)
|
||||
.order_by(PriceHistory.observed_date)
|
||||
)
|
||||
if category:
|
||||
query = query.where(NormalizedProduct.category == category)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
prices = result.scalars().all()
|
||||
|
||||
# Group by product
|
||||
by_product: dict[UUID, dict] = {}
|
||||
for ph in prices:
|
||||
pid = ph.normalized_product_id
|
||||
if pid not in by_product:
|
||||
by_product[pid] = {
|
||||
"product_id": pid,
|
||||
"product_name": ph.normalized_product.canonical_name,
|
||||
"data_points": [],
|
||||
}
|
||||
by_product[pid]["data_points"].append(
|
||||
{
|
||||
"date": ph.observed_date,
|
||||
"price": float(ph.regular_price),
|
||||
"store_id": ph.store_id,
|
||||
"store_name": ph.store.name,
|
||||
}
|
||||
)
|
||||
return list(by_product.values())
|
||||
|
||||
async def get_increases(self) -> list[dict]:
|
||||
"""Find products with recent significant price increases.
|
||||
|
||||
Uses a window function (lag) to compare each price observation with the
|
||||
previous one per product+store, avoiding the N+1 query pattern.
|
||||
"""
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory, Store
|
||||
|
||||
# Use lag() window function to get previous price in a single query
|
||||
prev_price = (
|
||||
func.lag(PriceHistory.regular_price)
|
||||
.over(
|
||||
partition_by=[PriceHistory.normalized_product_id, PriceHistory.store_id],
|
||||
order_by=PriceHistory.observed_date,
|
||||
)
|
||||
.label("prev_price")
|
||||
)
|
||||
|
||||
row_num = (
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=[PriceHistory.normalized_product_id, PriceHistory.store_id],
|
||||
order_by=PriceHistory.observed_date.desc(),
|
||||
)
|
||||
.label("rn")
|
||||
)
|
||||
|
||||
inner = select(
|
||||
PriceHistory.normalized_product_id,
|
||||
PriceHistory.store_id,
|
||||
PriceHistory.regular_price,
|
||||
PriceHistory.observed_date,
|
||||
prev_price,
|
||||
row_num,
|
||||
).subquery()
|
||||
|
||||
# Only keep the latest row (rn=1) where price increased
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
inner.c.normalized_product_id,
|
||||
inner.c.store_id,
|
||||
inner.c.regular_price,
|
||||
inner.c.observed_date,
|
||||
inner.c.prev_price,
|
||||
NormalizedProduct.canonical_name,
|
||||
Store.name.label("store_name"),
|
||||
)
|
||||
.join(NormalizedProduct, NormalizedProduct.id == inner.c.normalized_product_id)
|
||||
.join(Store, Store.id == inner.c.store_id)
|
||||
.where(
|
||||
inner.c.rn == 1,
|
||||
inner.c.prev_price.isnot(None),
|
||||
inner.c.regular_price > inner.c.prev_price,
|
||||
)
|
||||
)
|
||||
|
||||
increases = []
|
||||
for row in result.all():
|
||||
old = float(row.prev_price)
|
||||
new = float(row.regular_price)
|
||||
increases.append(
|
||||
{
|
||||
"product_id": row.normalized_product_id,
|
||||
"product_name": row.canonical_name,
|
||||
"store_name": row.store_name,
|
||||
"old_price": old,
|
||||
"new_price": new,
|
||||
"increase_pct": round((new - old) / old * 100, 2),
|
||||
"detected_at": row.observed_date,
|
||||
}
|
||||
)
|
||||
|
||||
increases.sort(key=lambda x: x["increase_pct"], reverse=True)
|
||||
return increases
|
||||
|
||||
async def get_comparison(self, product_ids: list[UUID]) -> list[dict]:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
if not product_ids:
|
||||
return []
|
||||
|
||||
# Fetch all requested products in one query
|
||||
prod_result = await self.db.execute(
|
||||
select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
|
||||
)
|
||||
products_by_id = {p.id: p for p in prod_result.scalars().all()}
|
||||
|
||||
# Latest prices for all requested products in one query
|
||||
subq = latest_price_per_store(product_ids)
|
||||
prices_result = await self.db.execute(
|
||||
select(PriceHistory)
|
||||
.join(
|
||||
subq,
|
||||
and_(
|
||||
PriceHistory.store_id == subq.c.store_id,
|
||||
PriceHistory.observed_date == subq.c.max_date,
|
||||
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
|
||||
),
|
||||
)
|
||||
.where(PriceHistory.normalized_product_id.in_(product_ids))
|
||||
.options(selectinload(PriceHistory.store))
|
||||
)
|
||||
all_prices = prices_result.scalars().all()
|
||||
|
||||
# Group prices by product
|
||||
prices_by_product: dict[UUID, list] = {pid: [] for pid in product_ids}
|
||||
for ph in all_prices:
|
||||
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
|
||||
|
||||
comparisons = []
|
||||
for pid in product_ids:
|
||||
product = products_by_id.get(pid)
|
||||
if not product:
|
||||
continue
|
||||
comparisons.append(
|
||||
{
|
||||
"product_id": pid,
|
||||
"product_name": product.canonical_name,
|
||||
"prices": [
|
||||
{
|
||||
"store_id": ph.store_id,
|
||||
"store_name": ph.store.name,
|
||||
"current_price": float(ph.regular_price),
|
||||
"last_seen_at": ph.observed_date,
|
||||
}
|
||||
for ph in prices_by_product.get(pid, [])
|
||||
],
|
||||
}
|
||||
)
|
||||
return comparisons
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Product service — catalog, detail, price history."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from cartsnitch_api.services.queries import latest_price_per_store
|
||||
|
||||
|
||||
class ProductService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_products(
|
||||
self,
|
||||
q: str | None = None,
|
||||
category: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> list[dict]:
|
||||
from cartsnitch_api.models import NormalizedProduct
|
||||
|
||||
query = select(NormalizedProduct)
|
||||
if q:
|
||||
# Escape SQL LIKE wildcards in user input
|
||||
safe_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query = query.where(NormalizedProduct.canonical_name.ilike(f"%{safe_q}%"))
|
||||
if category:
|
||||
query = query.where(NormalizedProduct.category == category)
|
||||
query = query.order_by(NormalizedProduct.canonical_name)
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
products = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.canonical_name,
|
||||
"brand": p.brand,
|
||||
"category": p.category,
|
||||
"upc": (p.upc_variants[0] if p.upc_variants else None),
|
||||
"image_url": None,
|
||||
}
|
||||
for p in products
|
||||
]
|
||||
|
||||
async def get_product(self, product_id: UUID) -> dict:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
result = await self.db.execute(
|
||||
select(NormalizedProduct).where(NormalizedProduct.id == product_id)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise LookupError("Product not found")
|
||||
|
||||
# Get latest price per store
|
||||
subq = latest_price_per_store([product_id])
|
||||
prices_result = await self.db.execute(
|
||||
select(PriceHistory)
|
||||
.join(
|
||||
subq,
|
||||
and_(
|
||||
PriceHistory.store_id == subq.c.store_id,
|
||||
PriceHistory.observed_date == subq.c.max_date,
|
||||
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
|
||||
),
|
||||
)
|
||||
.where(PriceHistory.normalized_product_id == product_id)
|
||||
.options(selectinload(PriceHistory.store))
|
||||
)
|
||||
prices = prices_result.scalars().all()
|
||||
|
||||
return {
|
||||
"id": product.id,
|
||||
"name": product.canonical_name,
|
||||
"brand": product.brand,
|
||||
"category": product.category,
|
||||
"upc": (product.upc_variants[0] if product.upc_variants else None),
|
||||
"image_url": None,
|
||||
"prices_by_store": [
|
||||
{
|
||||
"store_id": ph.store_id,
|
||||
"store_name": ph.store.name,
|
||||
"current_price": float(ph.regular_price),
|
||||
"last_seen_at": ph.observed_date,
|
||||
}
|
||||
for ph in prices
|
||||
],
|
||||
}
|
||||
|
||||
async def get_price_history(self, product_id: UUID) -> dict:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
result = await self.db.execute(
|
||||
select(NormalizedProduct).where(NormalizedProduct.id == product_id)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise LookupError("Product not found")
|
||||
|
||||
prices_result = await self.db.execute(
|
||||
select(PriceHistory)
|
||||
.where(PriceHistory.normalized_product_id == product_id)
|
||||
.options(selectinload(PriceHistory.store))
|
||||
.order_by(PriceHistory.observed_date)
|
||||
)
|
||||
prices = prices_result.scalars().all()
|
||||
|
||||
return {
|
||||
"product_id": product.id,
|
||||
"product_name": product.canonical_name,
|
||||
"data_points": [
|
||||
{
|
||||
"date": ph.observed_date,
|
||||
"price": float(ph.regular_price),
|
||||
"store_id": ph.store_id,
|
||||
"store_name": ph.store.name,
|
||||
}
|
||||
for ph in prices
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Public service — unauthenticated price transparency endpoints."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from cartsnitch_api.services.queries import latest_price_per_store
|
||||
|
||||
|
||||
class PublicService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_trend(self, product_id: UUID) -> dict:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
result = await self.db.execute(
|
||||
select(NormalizedProduct).where(NormalizedProduct.id == product_id)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise LookupError("Product not found")
|
||||
|
||||
prices_result = await self.db.execute(
|
||||
select(PriceHistory)
|
||||
.where(PriceHistory.normalized_product_id == product_id)
|
||||
.options(selectinload(PriceHistory.store))
|
||||
.order_by(PriceHistory.observed_date)
|
||||
)
|
||||
prices = prices_result.scalars().all()
|
||||
|
||||
return {
|
||||
"product_id": product.id,
|
||||
"product_name": product.canonical_name,
|
||||
"data_points": [
|
||||
{
|
||||
"date": ph.observed_date,
|
||||
"price": float(ph.regular_price),
|
||||
"store_id": ph.store_id,
|
||||
"store_name": ph.store.name,
|
||||
}
|
||||
for ph in prices
|
||||
],
|
||||
}
|
||||
|
||||
async def get_store_comparison(self, product_ids: list[UUID]) -> dict:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
if not product_ids:
|
||||
return {"products": []}
|
||||
|
||||
# Fetch all products in one query
|
||||
prod_result = await self.db.execute(
|
||||
select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
|
||||
)
|
||||
products_by_id = {p.id: p for p in prod_result.scalars().all()}
|
||||
|
||||
# Latest prices for all requested products in one query
|
||||
subq = latest_price_per_store(product_ids)
|
||||
prices_result = await self.db.execute(
|
||||
select(PriceHistory)
|
||||
.join(
|
||||
subq,
|
||||
and_(
|
||||
PriceHistory.store_id == subq.c.store_id,
|
||||
PriceHistory.observed_date == subq.c.max_date,
|
||||
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
|
||||
),
|
||||
)
|
||||
.where(PriceHistory.normalized_product_id.in_(product_ids))
|
||||
.options(selectinload(PriceHistory.store))
|
||||
)
|
||||
all_prices = prices_result.scalars().all()
|
||||
|
||||
# Group by product
|
||||
prices_by_product: dict[UUID, list] = {}
|
||||
for ph in all_prices:
|
||||
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
|
||||
|
||||
products = []
|
||||
for pid in product_ids:
|
||||
product = products_by_id.get(pid)
|
||||
if not product:
|
||||
continue
|
||||
products.append(
|
||||
{
|
||||
"product_id": pid,
|
||||
"product_name": product.canonical_name,
|
||||
"prices": [
|
||||
{
|
||||
"store_id": ph.store_id,
|
||||
"store_name": ph.store.name,
|
||||
"current_price": float(ph.regular_price),
|
||||
"last_seen_at": ph.observed_date,
|
||||
}
|
||||
for ph in prices_by_product.get(pid, [])
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {"products": products}
|
||||
|
||||
async def get_inflation(self) -> dict:
|
||||
"""Aggregate price change stats. Compares average prices across periods."""
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
# Get average prices grouped by category for recent vs older data
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
NormalizedProduct.category,
|
||||
func.avg(PriceHistory.regular_price),
|
||||
)
|
||||
.join(NormalizedProduct)
|
||||
.group_by(NormalizedProduct.category)
|
||||
)
|
||||
categories = {}
|
||||
for row in result.all():
|
||||
cat, avg_price = row
|
||||
if cat:
|
||||
categories[cat] = float(avg_price) if avg_price else 0.0
|
||||
|
||||
return {
|
||||
"period": "all-time",
|
||||
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
|
||||
"cpi_baseline": 100.0,
|
||||
"categories": categories,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Purchase service — list, detail, stats."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
||||
class PurchaseService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_purchases(
|
||||
self,
|
||||
user_id: UUID,
|
||||
store_id: UUID | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> list[dict]:
|
||||
from cartsnitch_api.models import Purchase, PurchaseItem, Store
|
||||
|
||||
# Count items per purchase in a single subquery instead of N+1
|
||||
item_counts = (
|
||||
select(
|
||||
PurchaseItem.purchase_id,
|
||||
func.count().label("item_count"),
|
||||
)
|
||||
.group_by(PurchaseItem.purchase_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Purchase, item_counts.c.item_count, Store.name.label("store_name"))
|
||||
.join(Store, Store.id == Purchase.store_id)
|
||||
.outerjoin(item_counts, item_counts.c.purchase_id == Purchase.id)
|
||||
.where(Purchase.user_id == user_id)
|
||||
)
|
||||
if store_id:
|
||||
query = query.where(Purchase.store_id == store_id)
|
||||
|
||||
query = query.order_by(Purchase.purchase_date.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"store_id": p.store_id,
|
||||
"store_name": store_name,
|
||||
"purchased_at": p.purchase_date,
|
||||
"total": float(p.total),
|
||||
"item_count": item_count or 0,
|
||||
}
|
||||
for p, item_count, store_name in result.all()
|
||||
]
|
||||
|
||||
async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict:
|
||||
from cartsnitch_api.models import Purchase
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Purchase)
|
||||
.where(Purchase.id == purchase_id, Purchase.user_id == user_id)
|
||||
.options(selectinload(Purchase.store), selectinload(Purchase.items))
|
||||
)
|
||||
purchase = result.scalar_one_or_none()
|
||||
if not purchase:
|
||||
raise LookupError("Purchase not found")
|
||||
|
||||
return {
|
||||
"id": purchase.id,
|
||||
"store_id": purchase.store_id,
|
||||
"store_name": purchase.store.name,
|
||||
"purchased_at": purchase.purchase_date,
|
||||
"total": float(purchase.total),
|
||||
"item_count": len(purchase.items),
|
||||
"line_items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"product_id": item.normalized_product_id,
|
||||
"name": item.product_name_raw,
|
||||
"quantity": float(item.quantity),
|
||||
"unit_price": float(item.unit_price),
|
||||
"total_price": float(item.extended_price),
|
||||
}
|
||||
for item in purchase.items
|
||||
],
|
||||
}
|
||||
|
||||
async def get_stats(self, user_id: UUID) -> dict:
|
||||
from cartsnitch_api.models import Purchase
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Purchase)
|
||||
.where(Purchase.user_id == user_id)
|
||||
.options(selectinload(Purchase.store))
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
total_spent = sum(float(p.total) for p in purchases)
|
||||
by_store: dict[str, float] = {}
|
||||
by_period: dict[str, float] = {}
|
||||
|
||||
for p in purchases:
|
||||
store_name = p.store.name
|
||||
by_store[store_name] = by_store.get(store_name, 0) + float(p.total)
|
||||
period = p.purchase_date.strftime("%Y-%m")
|
||||
by_period[period] = by_period.get(period, 0) + float(p.total)
|
||||
|
||||
return {
|
||||
"total_spent": total_spent,
|
||||
"purchase_count": len(purchases),
|
||||
"by_store": by_store,
|
||||
"by_period": by_period,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared query helpers for service layer."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
def latest_price_per_store(product_ids: list[UUID] | None = None):
|
||||
"""Subquery returning the latest observed_date per product+store.
|
||||
|
||||
Optionally filtered to a list of product IDs. Returns a subquery with
|
||||
columns: normalized_product_id, store_id, max_date.
|
||||
"""
|
||||
from cartsnitch_api.models import PriceHistory
|
||||
|
||||
query = select(
|
||||
PriceHistory.normalized_product_id,
|
||||
PriceHistory.store_id,
|
||||
func.max(PriceHistory.observed_date).label("max_date"),
|
||||
).group_by(PriceHistory.normalized_product_id, PriceHistory.store_id)
|
||||
if product_ids is not None:
|
||||
query = query.where(PriceHistory.normalized_product_id.in_(product_ids))
|
||||
return query.subquery()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""HTTP client for ReceiptWitness internal API."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class ReceiptWitnessClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.receiptwitness_url
|
||||
self.headers = {"X-Service-Key": settings.service_key}
|
||||
|
||||
async def trigger_sync(self, user_id: str, store_slug: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/sync/{store_slug}",
|
||||
headers=self.headers,
|
||||
json={"user_id": user_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], resp.json())
|
||||
|
||||
async def get_sync_status(self, user_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/sync/status",
|
||||
headers=self.headers,
|
||||
params={"user_id": user_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(list[dict[str, Any]], resp.json())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""HTTP client for ShrinkRay internal API."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class ShrinkRayClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.shrinkray_url
|
||||
self.headers = {"X-Service-Key": settings.service_key}
|
||||
|
||||
async def get_shrinkflation_alerts(self, user_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/alerts",
|
||||
headers=self.headers,
|
||||
params={"user_id": user_id},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(list[dict[str, Any]], resp.json())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""HTTP client for StickerShock internal API."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class StickerShockClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.stickershock_url
|
||||
self.headers = {"X-Service-Key": settings.service_key}
|
||||
|
||||
async def get_price_increases(self, params: dict | None = None) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/increases",
|
||||
headers=self.headers,
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(list[dict[str, Any]], resp.json())
|
||||
|
||||
async def get_inflation_data(self) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/inflation",
|
||||
headers=self.headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], resp.json())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Store service — list stores, manage user store account connections."""
|
||||
|
||||
import json
|
||||
from uuid import UUID
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
return Fernet(settings.fernet_key.encode())
|
||||
|
||||
|
||||
class StoreService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_stores(self) -> list[dict]:
|
||||
from cartsnitch_api.models import Store
|
||||
|
||||
result = await self.db.execute(select(Store).order_by(Store.name))
|
||||
stores = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"slug": s.slug,
|
||||
"logo_url": s.logo_url,
|
||||
"supported": True,
|
||||
}
|
||||
for s in stores
|
||||
]
|
||||
|
||||
async def list_user_stores(self, user_id: UUID) -> list[dict]:
|
||||
from cartsnitch_api.models import UserStoreAccount
|
||||
|
||||
result = await self.db.execute(
|
||||
select(UserStoreAccount)
|
||||
.where(UserStoreAccount.user_id == user_id)
|
||||
.options(selectinload(UserStoreAccount.store))
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"store": {
|
||||
"id": a.store.id,
|
||||
"name": a.store.name,
|
||||
"slug": a.store.slug,
|
||||
"logo_url": a.store.logo_url,
|
||||
"supported": True,
|
||||
},
|
||||
"connected": a.status == "active",
|
||||
"last_sync_at": a.last_sync_at,
|
||||
"sync_status": a.status,
|
||||
}
|
||||
for a in accounts
|
||||
]
|
||||
|
||||
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))
|
||||
store = result.scalar_one_or_none()
|
||||
if not store:
|
||||
raise LookupError(f"Store '{store_slug}' not found")
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(UserStoreAccount).where(
|
||||
UserStoreAccount.user_id == user_id,
|
||||
UserStoreAccount.store_id == store.id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError("Store account already connected")
|
||||
|
||||
encrypted_data = None
|
||||
if credentials:
|
||||
fernet = _get_fernet()
|
||||
encrypted_data = {
|
||||
"encrypted": fernet.encrypt(json.dumps(credentials).encode()).decode()
|
||||
}
|
||||
|
||||
account = UserStoreAccount(
|
||||
user_id=user_id,
|
||||
store_id=store.id,
|
||||
session_data=encrypted_data,
|
||||
status="active",
|
||||
)
|
||||
self.db.add(account)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(account)
|
||||
|
||||
return {
|
||||
"store": {
|
||||
"id": store.id,
|
||||
"name": store.name,
|
||||
"slug": store.slug,
|
||||
"logo_url": store.logo_url,
|
||||
"supported": True,
|
||||
},
|
||||
"connected": True,
|
||||
"last_sync_at": None,
|
||||
"sync_status": "active",
|
||||
}
|
||||
|
||||
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))
|
||||
store = result.scalar_one_or_none()
|
||||
if not store:
|
||||
raise LookupError(f"Store '{store_slug}' not found")
|
||||
|
||||
result = await self.db.execute(
|
||||
select(UserStoreAccount).where(
|
||||
UserStoreAccount.user_id == user_id,
|
||||
UserStoreAccount.store_id == store.id,
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise LookupError("Store account not connected")
|
||||
|
||||
await self.db.delete(account)
|
||||
await self.db.commit()
|
||||
Reference in New Issue
Block a user