""" 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}