forked from cartsnitch/cartsnitch
f36429936a
Syncs receiptwitness standalone repo code into monorepo subdirectory. Includes email parsing, notifications, queue, and worker modules. Keeps monorepo Dockerfile (uses local common/). Co-Authored-By: Paperclip <noreply@paperclip.ing>
114 lines
3.6 KiB
Python
114 lines
3.6 KiB
Python
"""Publish receipt ingestion events to Redis/DragonflyDB pub/sub."""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
|
|
import redis.asyncio as aioredis
|
|
from cartsnitch_common.database import get_async_session_factory
|
|
from cartsnitch_common.models.user import User
|
|
from sqlalchemy import select
|
|
|
|
from receiptwitness.config import settings
|
|
from receiptwitness.notifications.email import send_receipt_notification
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CHANNEL_RECEIPTS_INGESTED = "cartsnitch.receipts.ingested"
|
|
|
|
# Module-level connection pool — shared across all publish calls
|
|
_pool: aioredis.ConnectionPool | None = None
|
|
|
|
|
|
class _DecimalEncoder(json.JSONEncoder):
|
|
def default(self, o):
|
|
if isinstance(o, Decimal):
|
|
return float(o)
|
|
return super().default(o)
|
|
|
|
|
|
def _get_pool() -> aioredis.ConnectionPool:
|
|
"""Get or create the shared Redis connection pool."""
|
|
global _pool
|
|
if _pool is None:
|
|
_pool = aioredis.ConnectionPool.from_url(
|
|
settings.redis_url, decode_responses=True, max_connections=10
|
|
)
|
|
return _pool
|
|
|
|
|
|
async def get_redis_client() -> aioredis.Redis:
|
|
"""Create an async Redis/DragonflyDB client with connection pooling."""
|
|
return aioredis.Redis(connection_pool=_get_pool())
|
|
|
|
|
|
async def _send_notification_for_event(payload: dict) -> None:
|
|
"""Look up user email and send receipt notification. Silently skips on error."""
|
|
try:
|
|
user_uuid = uuid.UUID(payload["user_id"])
|
|
except (ValueError, KeyError):
|
|
logger.warning("Invalid user_id in event payload: %s", payload.get("user_id"))
|
|
return
|
|
|
|
try:
|
|
session_factory = get_async_session_factory(settings.database_url)
|
|
async with session_factory() as session:
|
|
result = await session.execute(select(User.email).where(User.id == user_uuid))
|
|
row = result.scalar_one_or_none()
|
|
if not row:
|
|
logger.warning("User %s not found for notification", user_uuid)
|
|
return
|
|
user_email = row
|
|
except Exception:
|
|
logger.exception("Failed to look up user email for notification")
|
|
return
|
|
|
|
await send_receipt_notification(
|
|
user_email=user_email,
|
|
store_name=payload["store_slug"],
|
|
item_count=payload["item_count"],
|
|
total=payload["total"],
|
|
purchase_date=payload["purchase_date"],
|
|
)
|
|
|
|
|
|
async def publish_receipt_ingested(
|
|
user_id: str,
|
|
store_slug: str,
|
|
purchase_id: str,
|
|
purchase_date: str,
|
|
item_count: int,
|
|
total: Decimal | float,
|
|
) -> None:
|
|
"""Publish a cartsnitch.receipts.ingested event after successful ingestion."""
|
|
payload = {
|
|
"user_id": user_id,
|
|
"store_slug": store_slug,
|
|
"purchase_id": purchase_id,
|
|
"purchase_date": purchase_date,
|
|
"item_count": item_count,
|
|
"total": float(total) if isinstance(total, Decimal) else total,
|
|
}
|
|
event = {
|
|
"event_type": CHANNEL_RECEIPTS_INGESTED,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"service": "receiptwitness",
|
|
"payload": payload,
|
|
}
|
|
|
|
try:
|
|
client = await get_redis_client()
|
|
await client.publish(CHANNEL_RECEIPTS_INGESTED, json.dumps(event, cls=_DecimalEncoder))
|
|
logger.info(
|
|
"Published %s event for purchase %s",
|
|
CHANNEL_RECEIPTS_INGESTED,
|
|
purchase_id,
|
|
)
|
|
except aioredis.ConnectionError:
|
|
logger.error("Failed to publish event — Redis/DragonflyDB connection error")
|
|
raise
|
|
else:
|
|
await _send_notification_for_event(payload)
|