venthrive-api / routes /help.py
GitHub Actions
Sync from GitHub · 2026-10-13 07:41 UTC
da1822e
Raw History Blame Contribute Delete
2.87 kB
"""
Help-centre feedback — the "Was this page helpful?" votes.
Public (no sign-in), so it stores no user id, name or email: only the page, the
vote, an optional reason and comment, and an anonymous per-browser-session id.
Comments are de-identified after 30 days by scripts/deidentify_help_feedback.py.
Writes go through here (never straight from the browser); firestore.rules deny
all client access to help_feedback.
"""
import time
import threading
from collections import defaultdict
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException
from firebase_admin import firestore
from pydantic import BaseModel, Field
router = APIRouter()
REASONS = {"not_found", "steps_failed", "out_of_date", "unclear", "other"}
RETENTION_SECONDS = 30 * 24 * 3600
# In-memory, per process (same trade-off as utils/rate_limiter.py). Keyed by the
# anonymous session id, plus a global cap so one client can't flood the collection.
PER_SESSION_LIMIT = (30, 3600) # 30 votes per hour
GLOBAL_LIMIT = (600, 3600) # 600 votes per hour, all sessions
_lock = threading.Lock()
_hits: dict[str, list[float]] = defaultdict(list)
def _allow(key: str, limit: tuple[int, int]) -> bool:
count, period = limit
now = time.time()
with _lock:
recent = [t for t in _hits[key] if now - t < period]
if len(recent) >= count:
_hits[key] = recent
return False
recent.append(now)
_hits[key] = recent
return True
class FeedbackIn(BaseModel):
pageId: str = Field(min_length=1, max_length=200, pattern=r"^[a-z0-9/_-]+$")
pageVersion: str = Field(min_length=1, max_length=40)
vote: Literal["yes", "no"]
reason: Optional[str] = Field(default=None, max_length=30)
comment: Optional[str] = Field(default=None, max_length=1000)
sessionId: str = Field(min_length=1, max_length=64)
device: Literal["mobile", "desktop"] = "desktop"
@router.post("/feedback")
def submit_feedback(body: FeedbackIn):
if not _allow(f"s:{body.sessionId}", PER_SESSION_LIMIT) or not _allow("global", GLOBAL_LIMIT):
raise HTTPException(status_code=429, detail="Too many requests. Try again later.")
reason = body.reason if body.reason in REASONS else None
now = time.time()
doc = {
"pageId": body.pageId,
"pageVersion": body.pageVersion,
"vote": body.vote,
"reason": reason,
"comment": (body.comment or "").strip() or None,
"sessionId": body.sessionId,
"device": body.device,
"at": firestore.SERVER_TIMESTAMP,
"deidentifyAfter": now + RETENTION_SECONDS,
"deidentified": False,
}
try:
firestore.client().collection("help_feedback").add(doc)
except Exception:
raise HTTPException(status_code=500, detail="Could not save feedback.")
return {"success": True}