smart-agent-store / database /supabase_client.py
yusef75's picture
Day 4: Revenue Protection & Conversion Hardening β€” autonomous cart recovery (30min Resend+Gemini), social proof toast API+frontend, checkout email capture, unified scheduler sanity check
9956841
Raw History Blame Contribute Delete
17.7 kB
"""Supabase client and helper functions for Project Memo.
Tables expected in Supabase (create via the SQL editor):
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT NOT NULL,
subtitle TEXT,
description TEXT,
bullet_points JSONB DEFAULT '[]'::jsonb,
images JSONB DEFAULT '[]'::jsonb,
category TEXT,
tags JSONB DEFAULT '[]'::jsonb,
aliexpress_id TEXT,
aliexpress_url TEXT,
base_cost NUMERIC(10,2) NOT NULL DEFAULT 0,
shipping_cost NUMERIC(10,2) NOT NULL DEFAULT 0,
current_price NUMERIC(10,2) NOT NULL DEFAULT 0,
markup_multiplier NUMERIC(4,2) NOT NULL DEFAULT 2.5,
demand_multiplier NUMERIC(4,2) NOT NULL DEFAULT 1.0,
ai_multiplier NUMERIC(4,2) NOT NULL DEFAULT 1.0,
stock_status TEXT NOT NULL DEFAULT 'in_stock',
rating NUMERIC(3,2) DEFAULT 0,
total_orders INTEGER DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
variants JSONB DEFAULT '[]'::jsonb,
seo_keywords JSONB DEFAULT '[]'::jsonb,
score INTEGER DEFAULT 0
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
shipping_address JSONB,
items JSONB NOT NULL,
subtotal NUMERIC(10,2) NOT NULL,
shipping_total NUMERIC(10,2) NOT NULL DEFAULT 0,
paypal_fee NUMERIC(10,2) NOT NULL DEFAULT 0,
total NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
paypal_order_id TEXT,
tracking_number TEXT,
notes TEXT
);
CREATE TABLE analytics (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
event_type TEXT NOT NULL,
source TEXT DEFAULT 'direct',
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE TABLE agent_status (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
agent_name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'idle',
last_run TIMESTAMPTZ,
runs_today INTEGER DEFAULT 0,
errors_today INTEGER DEFAULT 0,
metadata JSONB DEFAULT '{}'::jsonb
);
"""
from __future__ import annotations
import logging
import re
import uuid
from datetime import datetime, timezone
from typing import Any
from config import get_settings
logger = logging.getLogger("memo.db")
# ── Optional Supabase import ─────────────────────────────────────
# Gracefully handle missing supabase package (e.g. during UI-only preview)
_SUPABASE_AVAILABLE = False
_Client = None
_create_client = None
try:
from supabase import create_client as _create_client, Client as _Client
_SUPABASE_AVAILABLE = True
except ImportError:
logger.warning("supabase package not installed β€” database calls will return empty results")
# ── Singleton client ─────────────────────────────────────────────
_settings = get_settings()
_client: Any = None
def get_supabase() -> Any:
"""Return a lazily-initialised Supabase client.
Raises RuntimeError if the supabase package is not installed or
credentials are not configured.
"""
global _client
if not _SUPABASE_AVAILABLE or _create_client is None:
raise RuntimeError("Supabase package not installed β€” run: pip install supabase")
if _client is None:
_client = _create_client(_settings.supabase_url, _settings.supabase_key)
return _client
def _is_db_available() -> bool:
"""Check if Supabase is configured and reachable."""
if not _SUPABASE_AVAILABLE:
return False
if not _settings.supabase_url or not _settings.supabase_key:
return False
if "mock" in _settings.supabase_url.lower():
return False
return True
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ── Input Validation Guards ──────────────────────────────────────
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
def _validate_uuid(value: str, field_name: str = "id") -> str:
"""Validate that a string is a well-formed UUID.
Raises ValueError if not a valid UUID, preventing injection
through ID fields passed to Supabase query filters.
"""
if not value or not _UUID_RE.match(value):
raise ValueError(f"Invalid {field_name}: expected UUID format")
return value
def _validate_limit(limit: int, max_val: int = 500) -> int:
"""Clamp a limit parameter to a safe range."""
return max(1, min(int(limit), max_val))
def _sanitize_str(value: str, max_length: int = 1000) -> str:
"""Truncate and strip a string field to prevent oversized payloads."""
return value.strip()[:max_length] if value else value
# ── Products ─────────────────────────────────────────────────────
# ── Allowed sort columns (prevent injection via order_by) ───────
_ALLOWED_ORDER_COLUMNS = {"created_at", "updated_at", "current_price", "rating", "total_orders", "score", "title"}
def fetch_products(
*,
active_only: bool = True,
category: str | None = None,
limit: int = 50,
order_by: str = "created_at",
ascending: bool = False,
) -> list[dict[str, Any]]:
"""Return a list of products, optionally filtered."""
if not _is_db_available():
return []
# Validate order_by against allowlist to prevent column injection
if order_by not in _ALLOWED_ORDER_COLUMNS:
order_by = "created_at"
limit = _validate_limit(limit)
db = get_supabase()
query = db.table("products").select("*")
if active_only:
query = query.eq("is_active", True)
if category:
query = query.eq("category", _sanitize_str(category, 100))
query = query.order(order_by, desc=not ascending).limit(limit)
resp = query.execute()
return resp.data or []
def fetch_product(product_id: str) -> dict[str, Any] | None:
"""Return a single product by its UUID."""
if not _is_db_available():
return None
_validate_uuid(product_id, "product_id")
db = get_supabase()
resp = db.table("products").select("*").eq("id", product_id).single().execute()
return resp.data
def insert_product(data: dict[str, Any]) -> dict[str, Any]:
"""Insert a new product row."""
if not _is_db_available():
return data
db = get_supabase()
resp = db.table("products").insert(data).execute()
return resp.data[0] if resp.data else {}
def update_product(product_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update fields on an existing product."""
if not _is_db_available():
return data
_validate_uuid(product_id, "product_id")
db = get_supabase()
data["updated_at"] = _now_iso()
resp = db.table("products").update(data).eq("id", product_id).execute()
return resp.data[0] if resp.data else None
def upsert_product(data: dict[str, Any]) -> dict[str, Any]:
"""Insert or update a product (matched by aliexpress_id)."""
if not _is_db_available():
return data
existing = None
if data.get("aliexpress_id"):
db = get_supabase()
resp = (
db.table("products")
.select("id")
.eq("aliexpress_id", data["aliexpress_id"])
.limit(1)
.execute()
)
if resp.data:
existing = resp.data[0]
if existing:
return update_product(existing["id"], data) # type: ignore[arg-type]
return insert_product(data)
def count_products() -> int:
if not _is_db_available():
return 0
db = get_supabase()
resp = db.table("products").select("id", count="exact").execute()
return resp.count or 0 # type: ignore[return-value]
# ── Orders ───────────────────────────────────────────────────────
def fetch_orders(*, limit: int = 100, status: str | None = None) -> list[dict[str, Any]]:
if not _is_db_available():
return []
limit = _validate_limit(limit)
db = get_supabase()
query = db.table("orders").select("*")
if status:
query = query.eq("status", _sanitize_str(status, 50))
query = query.order("created_at", desc=True).limit(limit)
resp = query.execute()
return resp.data or []
def fetch_order(order_id: str) -> dict[str, Any] | None:
if not _is_db_available():
return None
_validate_uuid(order_id, "order_id")
db = get_supabase()
resp = db.table("orders").select("*").eq("id", order_id).single().execute()
return resp.data
def insert_order(data: dict[str, Any]) -> dict[str, Any]:
if not _is_db_available():
return data
db = get_supabase()
resp = db.table("orders").insert(data).execute()
return resp.data[0] if resp.data else {}
def update_order(order_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
if not _is_db_available():
return data
_validate_uuid(order_id, "order_id")
db = get_supabase()
data["updated_at"] = _now_iso()
resp = db.table("orders").update(data).eq("id", order_id).execute()
return resp.data[0] if resp.data else None
def update_order_by_paypal_id(paypal_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update an order matched by its PayPal order ID."""
if not _is_db_available():
return data
db = get_supabase()
data["updated_at"] = _now_iso()
resp = db.table("orders").update(data).eq("paypal_order_id", paypal_id).execute()
return resp.data[0] if resp.data else None
def fetch_order_by_paypal_id(paypal_id: str) -> dict[str, Any] | None:
"""Return a single order matched by its PayPal order ID."""
if not _is_db_available():
return None
db = get_supabase()
resp = db.table("orders").select("*").eq("paypal_order_id", paypal_id).limit(1).execute()
return resp.data[0] if resp.data else None
def count_orders() -> int:
if not _is_db_available():
return 0
db = get_supabase()
resp = db.table("orders").select("id", count="exact").execute()
return resp.count or 0 # type: ignore[return-value]
def total_revenue() -> float:
if not _is_db_available():
return 0.0
db = get_supabase()
resp = db.table("orders").select("total").in_("status", ["paid", "shipped", "delivered"]).execute()
return sum(float(o["total"]) for o in (resp.data or []))
# ── Analytics ────────────────────────────────────────────────────
def track_event(
product_id: str | None,
event_type: str,
source: str = "direct",
metadata: dict | None = None,
) -> None:
"""Insert a lightweight analytics event."""
if not _is_db_available():
return
db = get_supabase()
db.table("analytics").insert(
{
"product_id": product_id,
"event_type": event_type,
"source": source,
"metadata": metadata or {},
}
).execute()
def get_product_events(product_id: str, event_type: str, hours: int = 6) -> int:
"""Count events for a product within a time window."""
if not _is_db_available():
return 0
_validate_uuid(product_id, "product_id")
db = get_supabase()
resp = (
db.table("analytics")
.select("id", count="exact")
.eq("product_id", product_id)
.eq("event_type", _sanitize_str(event_type, 50))
.execute()
)
return resp.count or 0 # type: ignore[return-value]
def analytics_summary(hours: int = 24) -> dict[str, Any]:
"""Aggregate analytics for the admin dashboard."""
if not _is_db_available():
return {
"total_events": 0, "views": 0, "carts": 0, "purchases": 0,
"cart_rate": 0, "conversion_rate": 0, "sources": {},
}
db = get_supabase()
resp = db.table("analytics").select("*").execute()
events = resp.data or []
views = sum(1 for e in events if e["event_type"] == "view")
carts = sum(1 for e in events if e["event_type"] == "add_to_cart")
purchases = sum(1 for e in events if e["event_type"] == "purchase")
sources: dict[str, int] = {}
for e in events:
src = e.get("source", "direct")
sources[src] = sources.get(src, 0) + 1
return {
"total_events": len(events),
"views": views,
"carts": carts,
"purchases": purchases,
"cart_rate": round(carts / views * 100, 2) if views else 0,
"conversion_rate": round(purchases / views * 100, 2) if views else 0,
"sources": sources,
}
# ── Agent Status ─────────────────────────────────────────────────
def fetch_agent_statuses() -> list[dict[str, Any]]:
if not _is_db_available():
return []
db = get_supabase()
resp = db.table("agent_status").select("*").order("agent_name").execute()
return resp.data or []
def update_agent_status(agent_name: str, status: str, metadata: dict | None = None) -> None:
if not _is_db_available():
return
db = get_supabase()
db.table("agent_status").upsert(
{
"agent_name": agent_name,
"status": status,
"updated_at": _now_iso(),
"last_run": _now_iso(),
"metadata": metadata or {},
},
on_conflict="agent_name",
).execute()
# ── Recent Purchases (Social Proof) ──────────────────────────────
_SIMULATED_CITIES = [
"New York", "London", "Paris", "Berlin", "Tokyo", "Sydney", "Toronto",
"Amsterdam", "Stockholm", "Dubai", "Singapore", "Los Angeles", "Madrid",
"Rome", "Vienna", "Copenhagen", "Munich", "Barcelona", "Melbourne", "Zurich",
]
_SIMULATED_FIRST_NAMES = [
"Sarah", "James", "Emma", "Michael", "Olivia", "William", "Ava", "Oliver",
"Sophia", "Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan",
"Amelia", "Logan", "Harper", "Benjamin", "Evelyn", "Alexander", "Abigail", "Daniel",
]
def fetch_recent_purchases(*, limit: int = 10, fallback_to_simulated: bool = True) -> list[dict[str, Any]]:
"""Return recent successful orders formatted for social proof notifications.
If no real paid orders exist and fallback_to_simulated is True,
generates realistic simulated purchases from active products.
"""
if not _is_db_available():
return []
db = get_supabase()
resp = (
db.table("orders")
.select("*")
.in_("status", ["paid", "shipped", "delivered"])
.order("created_at", desc=True)
.limit(_validate_limit(limit, max_val=50))
.execute()
)
orders = resp.data or []
purchases: list[dict[str, Any]] = []
for order in orders:
items = order.get("items", [])
if not items:
continue
# Use first item for the notification
item = items[0]
# Anonymize name
raw_name = order.get("customer_name", "Someone")
first_name = raw_name.split()[0] if raw_name and raw_name != "Guest" else "Someone"
if first_name in {"Guest", "guest", ""}:
first_name = "Someone"
purchases.append({
"name": first_name,
"product_title": item.get("title", "a product"),
"location": "",
"time_ago": "recently",
"image": "",
"is_simulated": False,
})
if purchases or not fallback_to_simulated:
return purchases[:limit]
# ── Fallback: generate simulated purchases from active products ─
try:
product_resp = db.table("products").select("title, images, current_price").eq("is_active", True).limit(limit * 2).execute()
products = product_resp.data or []
except Exception:
products = []
import random
rng = random.Random(42) # deterministic seed for stability
rng.shuffle(products)
for i, product in enumerate(products[:limit]):
images = product.get("images", []) if isinstance(product.get("images"), list) else []
purchases.append({
"name": rng.choice(_SIMULATED_FIRST_NAMES),
"product_title": product.get("title", "a product"),
"location": rng.choice(_SIMULATED_CITIES),
"time_ago": f"{rng.randint(1, 45)} minutes ago",
"image": images[0] if images else "",
"is_simulated": True,
})
return purchases