""" Data access layer. `SupabaseStore` talks to real Supabase (Postgres) via the `supabase-py` client using the service-role key (so the backend can enforce tier/quota logic itself; RLS still protects direct client access as defense in depth). `InMemoryStore` is a drop-in replacement with the same interface, used only when SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY aren't configured (see config.DEMO_MODE), so the app can boot and be demoed without a real Supabase project. Swap in real credentials and the app talks to Postgres instead with zero code changes elsewhere. IMPORTANT: InMemoryStore data is process-local and lost on restart - never use it for anything beyond local development / sandbox demos. """ from __future__ import annotations import datetime as dt import threading import uuid from abc import ABC, abstractmethod from typing import Any from app.config import SUPABASE_CONFIGURED def current_year_month() -> str: return dt.datetime.utcnow().strftime("%Y-%m") class Store(ABC): # --- profiles / tier --------------------------------------------------- @abstractmethod def get_or_create_profile(self, user_id: str, email: str) -> dict: ... @abstractmethod def set_tier(self, user_id: str, tier: str, stripe_fields: dict | None = None) -> None: ... # --- generation counters ------------------------------------------------- @abstractmethod def get_generation_count(self, user_id: str) -> int: ... @abstractmethod def increment_generation_count(self, user_id: str) -> int: ... # --- brand voice profiles ------------------------------------------------ @abstractmethod def list_brand_voices(self, user_id: str) -> list[dict]: ... @abstractmethod def create_brand_voice(self, user_id: str, name: str, tone: str, audience: str, favorite_words: str) -> dict: ... @abstractmethod def delete_brand_voice(self, user_id: str, profile_id: str) -> None: ... # --- listings / history ---------------------------------------------------- @abstractmethod def save_listing(self, user_id: str, record: dict) -> dict: ... @abstractmethod def list_listings(self, user_id: str, since: dt.datetime | None) -> list[dict]: ... # --- GDPR: export + delete -------------------------------------------------- @abstractmethod def export_user_data(self, user_id: str) -> dict: ... @abstractmethod def delete_user_data(self, user_id: str) -> None: ... class InMemoryStore(Store): def __init__(self): self._lock = threading.Lock() self._profiles: dict[str, dict] = {} self._counters: dict[tuple[str, str], int] = {} self._brand_voices: dict[str, list[dict]] = {} self._listings: dict[str, list[dict]] = {} def get_or_create_profile(self, user_id: str, email: str) -> dict: with self._lock: if user_id not in self._profiles: self._profiles[user_id] = { "user_id": user_id, "email": email, "tier": "free", "stripe_customer_id": None, "stripe_subscription_id": None, "stripe_subscription_status": None, } return dict(self._profiles[user_id]) def set_tier(self, user_id: str, tier: str, stripe_fields: dict | None = None) -> None: with self._lock: profile = self._profiles.setdefault( user_id, {"user_id": user_id, "email": "", "tier": "free"} ) profile["tier"] = tier if stripe_fields: profile.update(stripe_fields) def get_generation_count(self, user_id: str) -> int: return self._counters.get((user_id, current_year_month()), 0) def increment_generation_count(self, user_id: str) -> int: with self._lock: key = (user_id, current_year_month()) self._counters[key] = self._counters.get(key, 0) + 1 return self._counters[key] def list_brand_voices(self, user_id: str) -> list[dict]: return list(self._brand_voices.get(user_id, [])) def create_brand_voice(self, user_id: str, name: str, tone: str, audience: str, favorite_words: str) -> dict: with self._lock: record = { "id": str(uuid.uuid4()), "user_id": user_id, "name": name, "tone": tone, "target_audience": audience, "favorite_words": favorite_words, "created_at": dt.datetime.utcnow().isoformat(), } self._brand_voices.setdefault(user_id, []).append(record) return record def delete_brand_voice(self, user_id: str, profile_id: str) -> None: with self._lock: self._brand_voices[user_id] = [ bv for bv in self._brand_voices.get(user_id, []) if bv["id"] != profile_id ] def save_listing(self, user_id: str, record: dict) -> dict: with self._lock: record = dict(record) record["id"] = str(uuid.uuid4()) record["user_id"] = user_id record["created_at"] = dt.datetime.utcnow().isoformat() self._listings.setdefault(user_id, []).insert(0, record) return record def list_listings(self, user_id: str, since: dt.datetime | None) -> list[dict]: listings = self._listings.get(user_id, []) if since is None: return list(listings) return [ l for l in listings if dt.datetime.fromisoformat(l["created_at"]) >= since ] def export_user_data(self, user_id: str) -> dict: return { "profile": self._profiles.get(user_id), "brand_voice_profiles": self._brand_voices.get(user_id, []), "listings": self._listings.get(user_id, []), } def delete_user_data(self, user_id: str) -> None: with self._lock: self._profiles.pop(user_id, None) self._brand_voices.pop(user_id, None) self._listings.pop(user_id, None) keys_to_drop = [k for k in self._counters if k[0] == user_id] for k in keys_to_drop: self._counters.pop(k, None) class SupabaseStore(Store): """Backed by real Supabase Postgres via the service-role key. The service role bypasses RLS by design (it's the trusted backend), which is why every method here takes an explicit user_id and filters on it - RLS in supabase_schema.sql is the defense-in-depth layer for any direct client access, not the only enforcement point. """ def __init__(self): # Imported lazily so `supabase` package is only required when this # class is actually instantiated (i.e. when real creds are present). from supabase import create_client import os url = os.environ["SUPABASE_URL"] key = os.environ["SUPABASE_SERVICE_ROLE_KEY"] self.client = create_client(url, key) def get_or_create_profile(self, user_id: str, email: str) -> dict: existing = self.client.table("profiles").select("*").eq("user_id", user_id).execute() if existing.data: return existing.data[0] inserted = self.client.table("profiles").insert( {"user_id": user_id, "email": email, "tier": "free"} ).execute() return inserted.data[0] def set_tier(self, user_id: str, tier: str, stripe_fields: dict | None = None) -> None: update = {"tier": tier} if stripe_fields: update.update(stripe_fields) self.client.table("profiles").update(update).eq("user_id", user_id).execute() def get_generation_count(self, user_id: str) -> int: ym = current_year_month() res = ( self.client.table("generation_counters") .select("count") .eq("user_id", user_id) .eq("year_month", ym) .execute() ) return res.data[0]["count"] if res.data else 0 def increment_generation_count(self, user_id: str) -> int: ym = current_year_month() current = self.get_generation_count(user_id) new_count = current + 1 self.client.table("generation_counters").upsert( {"user_id": user_id, "year_month": ym, "count": new_count} ).execute() return new_count def list_brand_voices(self, user_id: str) -> list[dict]: res = self.client.table("brand_voice_profiles").select("*").eq("user_id", user_id).execute() return res.data or [] def create_brand_voice(self, user_id: str, name: str, tone: str, audience: str, favorite_words: str) -> dict: res = self.client.table("brand_voice_profiles").insert( { "user_id": user_id, "name": name, "tone": tone, "target_audience": audience, "favorite_words": favorite_words, } ).execute() return res.data[0] def delete_brand_voice(self, user_id: str, profile_id: str) -> None: self.client.table("brand_voice_profiles").delete().eq("id", profile_id).eq("user_id", user_id).execute() def save_listing(self, user_id: str, record: dict) -> dict: payload = dict(record) payload["user_id"] = user_id res = self.client.table("listings").insert(payload).execute() return res.data[0] def list_listings(self, user_id: str, since: dt.datetime | None) -> list[dict]: query = self.client.table("listings").select("*").eq("user_id", user_id).order("created_at", desc=True) if since is not None: query = query.gte("created_at", since.isoformat()) return query.execute().data or [] def export_user_data(self, user_id: str) -> dict: profile = self.client.table("profiles").select("*").eq("user_id", user_id).execute().data brand_voices = self.list_brand_voices(user_id) listings = self.list_listings(user_id, since=None) return { "profile": profile[0] if profile else None, "brand_voice_profiles": brand_voices, "listings": listings, } def delete_user_data(self, user_id: str) -> None: # Order matters for FK constraints without cascade guarantees on some # setups; delete children before parent, then the auth user itself. self.client.table("listings").delete().eq("user_id", user_id).execute() self.client.table("brand_voice_profiles").delete().eq("user_id", user_id).execute() self.client.table("generation_counters").delete().eq("user_id", user_id).execute() self.client.table("shops").delete().eq("user_id", user_id).execute() self.client.table("profiles").delete().eq("user_id", user_id).execute() # Also remove the underlying Supabase Auth user (requires service role). self.client.auth.admin.delete_user(user_id) _store_instance: Store | None = None def get_store() -> Store: global _store_instance if _store_instance is None: _store_instance = SupabaseStore() if SUPABASE_CONFIGURED else InMemoryStore() return _store_instance