simikkk's picture
Upload 36 files
16e1aa7 verified
Raw
History Blame Contribute Delete
11.3 kB
"""
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