Frontend: - LandingPage.jsx: hero, 6 feature cards, AI Scribe section (links peds.danvics.com), contact form - Contact form: name/email/type toggle (question vs moderator app), Turnstile anti-bot (optional) - App.jsx: /home route for unauthenticated users, auth pages reachable pre-login, logged-in /home → / Backend: - contact.py: POST /api/contact (public), stores in DB, emails admin; GET /submissions (admin) - main.py: create contact_submissions table, register contact router - main.py: add pedshub.com + www.pedshub.com to CORS origins - config.py: TURNSTILE_SECRET_KEY, ADMIN_EMAIL settings Env vars to set: VITE_TURNSTILE_SITE_KEY=<from cloudflare.com/turnstile> (frontend) TURNSTILE_SECRET_KEY=<secret> (backend .env) ADMIN_EMAIL=you@example.com (backend .env) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
140 lines
4.8 KiB
Python
140 lines
4.8 KiB
Python
"""Contact form — stores submissions and emails admin."""
|
|
from datetime import datetime
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy import Column, Integer, String, Text, DateTime
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db, Base
|
|
from app.config import settings
|
|
|
|
router = APIRouter()
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class ContactSubmission(Base):
|
|
__tablename__ = "contact_submissions"
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String, nullable=False)
|
|
email = Column(String, nullable=False)
|
|
type = Column(String, nullable=False) # "question" | "moderator"
|
|
message = Column(Text, nullable=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
read = Column(Integer, default=0)
|
|
|
|
|
|
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")
|
|
|
|
submission = ContactSubmission(
|
|
name=name,
|
|
email=req.email.lower().strip(),
|
|
type=req.type,
|
|
message=message,
|
|
)
|
|
db.add(submission)
|
|
db.commit()
|
|
|
|
# Email admin
|
|
_email_admin(name, req.email, req.type, message)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
def _email_admin(name: str, email: str, type_: str, message: str):
|
|
try:
|
|
from app.services.email_service import send_email
|
|
label = "Moderator Application" if type_ == "moderator" else "Contact Form Question"
|
|
body = (
|
|
f"<h2>New {label} — PedsHub</h2>"
|
|
f"<p><strong>From:</strong> {name} <{email}></p>"
|
|
f"<p><strong>Message:</strong></p>"
|
|
f"<blockquote style='border-left:4px solid #2563eb;padding-left:12px;color:#334155'>"
|
|
f"{message.replace(chr(10), '<br>')}"
|
|
f"</blockquote>"
|
|
)
|
|
admin_email = getattr(settings, "ADMIN_EMAIL", None) or getattr(settings, "SMTP_FROM", None)
|
|
if admin_email:
|
|
send_email(to=admin_email, subject=f"[PedsHub] {label} from {name}", html=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.query(ContactSubmission).order_by(ContactSubmission.created_at.desc()).all()
|
|
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),
|
|
):
|
|
row = db.query(ContactSubmission).filter(ContactSubmission.id == submission_id).first()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
row.read = 1
|
|
db.commit()
|
|
return {"success": True}
|