package api import ( "encoding/json" "errors" "net/http" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) type guestHandler struct { guests *storage.GuestRepo events *storage.EventRepo collabs *storage.CollaboratorRepo enforcer *tierEnforcer } type createGuestRequest struct { Name string `json:"name"` Email *string `json:"email"` Phone *string `json:"phone"` PlusOnes int `json:"plus_ones"` DietaryNotes *string `json:"dietary_notes"` TableNumber *int `json:"table_number"` } func (h *guestHandler) create(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) { return } var req createGuestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if req.PlusOnes < 0 { writeError(w, http.StatusBadRequest, "plus_ones must be >= 0") return } g, err := h.guests.Create(r.Context(), storage.CreateGuestParams{ EventID: eventID, Name: req.Name, Email: req.Email, Phone: req.Phone, PlusOnes: req.PlusOnes, DietaryNotes: req.DietaryNotes, TableNumber: req.TableNumber, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create guest") return } writeJSON(w, http.StatusCreated, g) } type updateGuestRequest struct { Name *string `json:"name"` Email *string `json:"email"` Phone *string `json:"phone"` PlusOnes *int `json:"plus_ones"` } // PATCH /events/{id}/guests/{guest_id} — patch a guest's contact info. // Fields omitted from the body are left untouched. Empty strings for // email/phone clear those columns to NULL. func (h *guestHandler) update(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } var req updateGuestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Name != nil && *req.Name == "" { writeError(w, http.StatusBadRequest, "name cannot be empty") return } if req.PlusOnes != nil && *req.PlusOnes < 0 { writeError(w, http.StatusBadRequest, "plus_ones must be >= 0") return } g, err := h.guests.Update(r.Context(), eventID, guestID, storage.UpdateGuestParams{ Name: req.Name, Email: req.Email, Phone: req.Phone, PlusOnes: req.PlusOnes, }) if err != nil { if errors.Is(err, domain.ErrGuestNotFound) { writeError(w, http.StatusNotFound, "guest not found") return } writeError(w, http.StatusInternalServerError, "failed to update guest") return } writeJSON(w, http.StatusOK, g) } // DELETE /events/{id}/guests/{guest_id} — remove a guest from an event. // Cascade-deletes their token, rsvp, access logs, notifications. func (h *guestHandler) delete(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } if err := h.guests.Delete(r.Context(), eventID, guestID); err != nil { if errors.Is(err, domain.ErrGuestNotFound) { writeError(w, http.StatusNotFound, "guest not found") return } writeError(w, http.StatusInternalServerError, "failed to delete guest") return } w.WriteHeader(http.StatusNoContent) } func (h *guestHandler) list(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } // Listing guests is viewer+. Editing is gated separately at the // PATCH/POST/DELETE call sites. if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok { return } q := r.URL.Query() limit := atoiOr(q.Get("limit"), 100) offset := atoiOr(q.Get("offset"), 0) guests, err := h.guests.ListByEventWithRSVP(r.Context(), eventID, limit, offset) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list guests") return } if guests == nil { guests = []*storage.GuestWithRSVP{} } stats := struct { Total int `json:"total"` Attending int `json:"attending"` Declined int `json:"declined"` Maybe int `json:"maybe"` Pending int `json:"pending"` }{Total: len(guests)} for _, g := range guests { switch { case g.RSVPResponse == nil: stats.Pending++ case *g.RSVPResponse == string(domain.RSVPAttending): stats.Attending++ case *g.RSVPResponse == string(domain.RSVPDeclined): stats.Declined++ case *g.RSVPResponse == string(domain.RSVPMaybe): stats.Maybe++ } } writeJSON(w, http.StatusOK, map[string]any{ "guests": guests, "stats": stats, "limit": limit, "offset": offset, }) }