Add yes/no RSVP answers with required login

RSVPs now carry a status ('yes'/'no'). When auth is enabled, answering
requires login for all events, not just capped ones. Answers are deduped
one per user and re-answering updates in place. A 'no' zeroes plus_one,
is excluded from Going counts and cap math, frees the person's slot
claims, and shows in a separate "Can't make it" list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 07:59:33 -04:00
parent 891b49b4c0
commit cf2ef77bb0
12 changed files with 341 additions and 107 deletions
+147 -69
View File
@@ -170,12 +170,14 @@ type EventPageData struct {
Slots []SlotView
Rsvps []db.Rsvp
GoingList []GoingPerson
NotGoingList []GoingPerson
TotalGoing int64
IsAdmin bool
BaseURL string
DescriptionHTML template.HTML
User *db.User
AuthEnabled bool
CanEditRsvps bool
Capped bool
SpotsLeft int64
CapFull bool
@@ -228,14 +230,15 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
return nil, err
}
var totalGoing int64
var goingList, notGoingList []GoingPerson
for _, r := range rsvps {
p := GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne}
if r.Status == "no" {
notGoingList = append(notGoingList, p)
continue
}
totalGoing += 1 + r.PlusOne
}
// Build GoingList from RSVPs only
var goingList []GoingPerson
for _, r := range rsvps {
goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne})
goingList = append(goingList, p)
}
var descHTML template.HTML
@@ -259,17 +262,20 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
}
}
user := s.currentUser(r)
return &EventPageData{
Event: event,
Slots: slotViews,
Rsvps: rsvps,
GoingList: goingList,
NotGoingList: notGoingList,
TotalGoing: totalGoing,
IsAdmin: isAdmin,
BaseURL: s.baseURL,
DescriptionHTML: descHTML,
User: s.currentUser(r),
User: user,
AuthEnabled: s.features.Auth,
CanEditRsvps: isAdmin || !s.features.Auth || user != nil,
Capped: capped,
SpotsLeft: spotsLeft,
CapFull: capped && totalGoing >= event.AttendeeCap,
@@ -356,12 +362,19 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
return
}
note := sanitize(r.FormValue("note"), maxNoteLen)
status := "yes"
if r.FormValue("status") == "no" {
status = "no"
}
plusOne := int64(0)
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
plusOne = v
}
if plusOne > 10 {
plusOne = 10
if plusOne > maxPlusOne {
plusOne = maxPlusOne
}
if status == "no" {
plusOne = 0
}
event, err := s.q.GetEventBySlug(r.Context(), slug)
@@ -370,40 +383,56 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
return
}
// Capped events require login and enforce the cap on total people going.
capped := s.features.Auth && event.AttendeeCap > 0
// Answering — yes or no — requires login when auth is enabled.
user := s.currentUser(r)
alreadyRsvped := false
if capped {
if user == nil {
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
return
}
_, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
if s.features.Auth && user == nil {
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
return
}
// One answer per person: find an existing RSVP by user, falling back to name.
var existing *db.Rsvp
if user != nil {
if rv, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
EventID: event.ID,
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
})
alreadyRsvped = err == nil
if !alreadyRsvped {
going, err := s.q.CountGoing(r.Context(), event.ID)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if going+1+plusOne > event.AttendeeCap {
left := event.AttendeeCap - going
if left <= 0 {
http.Error(w, "Event is full", http.StatusConflict)
} else {
http.Error(w, fmt.Sprintf("Only %d spot%s left", left, plural(left)), http.StatusConflict)
}
return
}
}); err == nil {
existing = &rv
}
}
if existing == nil {
if rv, err := s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
EventID: event.ID, Name: name,
}); err == nil {
existing = &rv
}
}
// Optional slot claim
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
// Capped events enforce the cap on total people going.
capped := s.features.Auth && event.AttendeeCap > 0
if capped && status == "yes" {
going, err := s.q.CountGoing(r.Context(), event.ID)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
var current int64
if existing != nil && existing.Status == "yes" {
current = 1 + existing.PlusOne
}
if going-current+1+plusOne > event.AttendeeCap {
left := event.AttendeeCap - (going - current)
if left <= 0 {
http.Error(w, "Event is full", http.StatusConflict)
} else {
http.Error(w, fmt.Sprintf("Only %d spot%s left", left, plural(left)), http.StatusConflict)
}
return
}
}
// Optional slot claim (only when going)
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" && status == "yes" {
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
if err != nil {
http.Error(w, "Invalid slot", http.StatusBadRequest)
@@ -440,34 +469,50 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
}
}
// Create RSVP (deduped — skip if already on the list)
if !alreadyRsvped {
// Record the answer: update the existing RSVP or create a new one.
if existing != nil {
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
Name: name, Note: note, PlusOne: plusOne, Status: status, ID: existing.ID,
})
if err != nil {
log.Printf("update rsvp: %v", err)
http.Error(w, "Failed", http.StatusInternalServerError)
return
}
} else {
count, err := s.q.CountRsvps(r.Context(), event.ID)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if count >= maxRsvps {
http.Error(w, "RSVP list is full", http.StatusConflict)
return
}
var userID sql.NullInt64
if user != nil {
userID = sql.NullInt64{Int64: user.ID, Valid: true}
}
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, Status: status, UserID: userID,
})
if err != nil {
log.Printf("create rsvp: %v", err)
http.Error(w, "Failed", http.StatusInternalServerError)
return
}
}
// Not coming — free any slots claimed under their name.
if status == "no" {
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
EventID: event.ID, Name: name,
})
if err == sql.ErrNoRows {
count, err := s.q.CountRsvps(r.Context(), event.ID)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if count >= maxRsvps {
http.Error(w, "RSVP list is full", http.StatusConflict)
return
}
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, UserID: userID,
if existing != nil && !strings.EqualFold(existing.Name, name) {
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
EventID: event.ID, Name: existing.Name,
})
if err != nil {
log.Printf("create rsvp: %v", err)
http.Error(w, "Failed", http.StatusInternalServerError)
return
}
}
}
@@ -509,6 +554,11 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
return
}
if s.features.Auth && s.currentUser(r) == nil {
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
r.ParseForm()
@@ -518,12 +568,19 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
return
}
note := sanitize(r.FormValue("note"), maxNoteLen)
status := "yes"
if r.FormValue("status") == "no" {
status = "no"
}
plusOne := int64(0)
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
plusOne = v
}
if plusOne > 10 {
plusOne = 10
if plusOne > maxPlusOne {
plusOne = maxPlusOne
}
if status == "no" {
plusOne = 0
}
event, err := s.q.GetEventBySlug(r.Context(), slug)
@@ -531,32 +588,48 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
// Raising plus_one can't exceed the attendee cap.
if s.features.Auth && event.AttendeeCap > 0 {
old, err := s.q.GetRsvp(r.Context(), rsvpID)
if err != nil {
http.Error(w, "RSVP not found", http.StatusNotFound)
return
}
old, err := s.q.GetRsvp(r.Context(), rsvpID)
if err != nil {
http.Error(w, "RSVP not found", http.StatusNotFound)
return
}
// Raising plus_one (or flipping back to yes) can't exceed the attendee cap.
if s.features.Auth && event.AttendeeCap > 0 && status == "yes" {
going, err := s.q.CountGoing(r.Context(), event.ID)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if going-old.PlusOne+plusOne > event.AttendeeCap {
var current int64
if old.Status == "yes" {
current = 1 + old.PlusOne
}
if going-current+1+plusOne > event.AttendeeCap {
http.Error(w, "Not enough spots left", http.StatusConflict)
return
}
}
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
Name: name, Note: note, PlusOne: plusOne, Status: status, ID: rsvpID,
})
if err != nil {
http.Error(w, "Failed to update", http.StatusInternalServerError)
return
}
// Not coming — free any slots claimed under their name.
if status == "no" {
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
EventID: event.ID, Name: name,
})
if !strings.EqualFold(old.Name, name) {
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
EventID: event.ID, Name: old.Name,
})
}
}
s.notify(slug)
w.Header().Set("HX-Trigger", "closeModal")
@@ -576,6 +649,11 @@ func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) {
return
}
if s.features.Auth && s.currentUser(r) == nil {
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
return
}
s.q.DeleteRsvp(r.Context(), rsvpID)
s.notify(slug)