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:
+147
-69
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user