"""Contact form — stores submissions and emails admin. Table is created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers. """ import logging from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr from sqlalchemy import text from sqlalchemy.orm import Session from app.database import get_db from app.config import settings router = APIRouter() log = logging.getLogger(__name__) class ContactRequest(BaseModel): name: str email: EmailStr type: str # "question" | "moderator" message: str turnstile_token: str | None = None async def _verify_turnstile(token: str) -> bool: """Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured.""" secret = getattr(settings, "TURNSTILE_SECRET_KEY", None) if not secret: return True # Not configured — allow in dev/no-captcha mode try: import httpx resp = httpx.post( "https://challenges.cloudflare.com/turnstile/v0/siteverify", data={"secret": secret, "response": token}, timeout=10, ) return resp.json().get("success", False) except Exception as e: log.warning(f"Turnstile verification failed: {e}") return False @router.post("") async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)): """Public endpoint — no auth required.""" if req.type not in ("question", "moderator"): raise HTTPException(status_code=400, detail="Type must be 'question' or 'moderator'") name = req.name.strip()[:120] message = req.message.strip()[:2000] if not name or not message: raise HTTPException(status_code=400, detail="Name and message are required") # Verify Turnstile if configured secret = getattr(settings, "TURNSTILE_SECRET_KEY", None) if secret: if not req.turnstile_token: raise HTTPException(status_code=400, detail="Bot verification required") if not await _verify_turnstile(req.turnstile_token): raise HTTPException(status_code=400, detail="Bot verification failed — please try again") db.execute(text( "INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)" ), {"name": name, "email": req.email.lower().strip(), "type": req.type, "message": message}) db.commit() # Email admin await _email_admin(name, req.email, req.type, message) return {"success": True} async def _email_admin(name: str, email: str, type_: str, message: str): try: from app.services.email_service import _send label = "Moderator Application" if type_ == "moderator" else "Contact Form Question" body = ( f"

New {label} — PedsHub

" f"

From: {name} <{email}>

" f"

Message:

" f"
" f"{message.replace(chr(10), '
')}" f"
" ) admin_email = getattr(settings, "ADMIN_EMAIL", None) or getattr(settings, "SMTP_FROM", None) if admin_email: await _send(admin_email, f"[PedsHub] {label} from {name}", body) except Exception as e: log.warning(f"Failed to email admin about contact submission: {e}") # ── Admin view of submissions ───────────────────────────────────────────────── @router.get("/submissions") def list_submissions( db: Session = Depends(get_db), ): """Admin: list all contact submissions.""" from app.utils.auth import require_admin rows = db.execute(text( "SELECT id, name, email, type, message, read, created_at FROM contact_submissions ORDER BY created_at DESC" )).fetchall() return [ { "id": r.id, "name": r.name, "email": r.email, "type": r.type, "message": r.message, "read": r.read, "created_at": r.created_at.isoformat(), } for r in rows ] @router.put("/submissions/{submission_id}/read") def mark_read( submission_id: int, db: Session = Depends(get_db), ): result = db.execute(text("UPDATE contact_submissions SET read=1 WHERE id=:id"), {"id": submission_id}) db.commit() if result.rowcount == 0: raise HTTPException(status_code=404, detail="Not found") return {"success": True}