| """ |
| 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): |
| |
| @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: ... |
|
|
| |
| @abstractmethod |
| def get_generation_count(self, user_id: str) -> int: ... |
|
|
| @abstractmethod |
| def increment_generation_count(self, user_id: str) -> int: ... |
|
|
| |
| @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: ... |
|
|
| |
| @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]: ... |
|
|
| |
| @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): |
| |
| |
| 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: |
| |
| |
| 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() |
| |
| 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 |
|
|