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:
Coupon Carl
2026-03-28 02:24:02 +00:00
commit b7e6f637a7
91 changed files with 6296 additions and 0 deletions
+75
View File
@@ -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
+125
View File
@@ -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,
}
+52
View File
@@ -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())
+76
View File
@@ -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,
}
+183
View File
@@ -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
+124
View File
@@ -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
],
}
+129
View File
@@ -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,
}
+116
View File
@@ -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,
}
+23
View File
@@ -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())
+23
View File
@@ -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())
+129
View File
@@ -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()