From a845f9db581d5e74e477cf2b067d810683c2bc2d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 5 Mar 2026 01:51:27 -0500 Subject: [PATCH] feat: add invite status check and token validation on submit Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/survey.py | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 backend/app/api/endpoints/survey.py diff --git a/backend/app/api/endpoints/survey.py b/backend/app/api/endpoints/survey.py new file mode 100644 index 00000000..bdd14d4f --- /dev/null +++ b/backend/app/api/endpoints/survey.py @@ -0,0 +1,90 @@ +"""Public survey submission endpoint. No authentication required.""" +import logging +from typing import Annotated + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.email import EmailService +from app.core.rate_limit import limiter +from app.models.survey_invite import SurveyInvite +from app.models.survey_response import SurveyResponse +from app.schemas.survey import SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["survey"]) + + +@router.get("/survey/invite/{token}", response_model=SurveyInviteStatus) +async def check_invite_status( + token: str, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Check if a survey invite token is valid and its status.""" + result = await db.execute( + select(SurveyInvite).where(SurveyInvite.token == token) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(status_code=404, detail="Invalid invite token") + return SurveyInviteStatus(name=invite.recipient_name, status=invite.status) + + +@router.post("/survey/submit", response_model=SurveySubmissionResponse) +@limiter.limit("3/hour") +async def submit_survey( + request: Request, + data: SurveySubmission, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Accept a public survey submission. No auth required. + + Rate limited to 3/hour per IP to prevent spam. + Saves to DB and sends notification email to configured address. + """ + ip = request.client.host if request.client else None + ua = request.headers.get("user-agent", "") + + invite = None + if data.token: + result = await db.execute( + select(SurveyInvite).where(SurveyInvite.token == data.token) + ) + invite = result.scalar_one_or_none() + if invite and invite.status == "completed": + raise HTTPException(status_code=409, detail="This survey has already been submitted") + + response = SurveyResponse( + respondent_name=data.respondent_name or (invite.recipient_name if invite else None), + responses=data.responses, + ip_address=ip, + user_agent=ua, + invite_id=invite.id if invite else None, + ) + db.add(response) + + if invite: + invite.status = "completed" + invite.completed_at = datetime.now(timezone.utc) + + await db.flush() + + try: + if settings.FEEDBACK_EMAIL: + await EmailService.send_survey_notification_email( + to_email=settings.FEEDBACK_EMAIL, + respondent_name=response.respondent_name, + responses=data.responses, + ) + except Exception: + logger.exception("Failed to send survey notification email") + + await db.commit() + + return SurveySubmissionResponse(id=str(response.id))