package api import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "time" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/fraud" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/storage" ) type rsvpPublisher interface { PublishRSVPConfirmed(ctx context.Context, evt natspub.RSVPConfirmed) error } type fraudScorer interface { Score(ctx context.Context, in fraud.ScoreInput) fraud.Decision } type rsvpHandler struct { logger *slog.Logger guests *storage.GuestRepo tokens *storage.TokenRepo events *storage.EventRepo rsvps *storage.RSVPRepo accessLogs *storage.AccessLogRepo scorer fraudScorer pub rsvpPublisher } type submitRSVPRequest struct { Response string `json:"response"` PlusOnes int `json:"plus_ones"` DietaryNotes *string `json:"dietary_notes"` Fingerprint map[string]any `json:"fingerprint"` } type submitRSVPResponse struct { RSVP *domain.RSVP `json:"rsvp"` Decision fraud.Decision `json:"fraud"` Blocked bool `json:"blocked"` } // POST /rsvp/{token} — synchronous fraud check + RSVP recording. func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) { raw := r.PathValue("token") if err := auth.ValidateFormat(raw); err != nil { writeError(w, http.StatusBadRequest, "malformed token") return } tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) if err != nil { if errors.Is(err, domain.ErrTokenNotFound) { writeError(w, http.StatusNotFound, "token not found") return } writeError(w, http.StatusInternalServerError, "failed to load token") return } if err := tk.IsValid(time.Now().UTC()); err != nil { writeError(w, http.StatusGone, err.Error()) return } var req submitRSVPRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } resp := domain.RSVPResponse(req.Response) if !resp.Valid() { writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe") return } if req.PlusOnes < 0 { writeError(w, http.StatusBadRequest, "plus_ones must be >= 0") return } guest, err := h.guests.Get(r.Context(), tk.GuestID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guest") return } if req.PlusOnes > guest.PlusOnes { writeError(w, http.StatusBadRequest, fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes)) return } event, err := h.events.Get(r.Context(), guest.EventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load event") return } fingerprint := mergeFingerprint(req.Fingerprint, collectFingerprint(r)) ip := clientIP(r) accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{ GuestID: guest.ID, TokenID: tk.ID, Fingerprint: fingerprint, IPAddress: ip, }) if err != nil { h.logger.Error("create access log", "err", err) } decision := h.scorer.Score(r.Context(), fraud.ScoreInput{ EventID: event.ID, GuestID: guest.ID, TokenID: tk.ID, AccessLogID: accessLogID, Fingerprint: stringifyFingerprint(fingerprint), IPAddress: ip, UserAgent: r.UserAgent(), Referrer: r.Referer(), }) if fraud.IsBlock(decision) { writeJSON(w, http.StatusForbidden, submitRSVPResponse{ Decision: decision, Blocked: true, }) return } score := decision.Score rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{ GuestID: guest.ID, Response: resp, PlusOnes: req.PlusOnes, DietaryNotes: req.DietaryNotes, DeviceFingerprint: fingerprint, IPAddress: ip, RiskScore: &score, }) if err != nil { if errors.Is(err, domain.ErrRSVPAlreadySubmitted) { writeError(w, http.StatusConflict, "rsvp already submitted for this guest") return } h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID) writeError(w, http.StatusInternalServerError, "failed to record rsvp") return } if err := h.tokens.MarkUsed(r.Context(), tk.ID); err != nil { h.logger.Warn("mark token used", "err", err, "token_id", tk.ID) } go func(evt natspub.RSVPConfirmed) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := h.pub.PublishRSVPConfirmed(ctx, evt); err != nil { h.logger.Error("publish rsvp.confirmed", "err", err, "rsvp_id", evt.RSVPID) } }(natspub.RSVPConfirmed{ EventID: event.ID, GuestID: guest.ID, RSVPID: rsvp.ID, Response: string(rsvp.Response), PlusOnes: rsvp.PlusOnes, RiskScore: &score, SubmittedAt: rsvp.SubmittedAt, }) writeJSON(w, http.StatusCreated, submitRSVPResponse{ RSVP: rsvp, Decision: decision, Blocked: false, }) } func mergeFingerprint(client map[string]any, server map[string]any) map[string]any { out := make(map[string]any, len(server)+len(client)) for k, v := range server { out[k] = v } for k, v := range client { out["client_"+k] = v } return out } func stringifyFingerprint(fp map[string]any) map[string]string { if fp == nil { return nil } out := make(map[string]string, len(fp)) for k, v := range fp { switch tv := v.(type) { case string: out[k] = tv default: b, _ := json.Marshal(tv) out[k] = string(b) } } return out }