Add attendee cap with required login for capped events

Hosts can optionally cap total attendance (RSVPs + plus-ones) when
creating an event. Capped events require guests to log in via the
existing email code flow; RSVPs are tied to accounts and deduped per
user. The event page shows spots left and replaces the form with a
full notice when the cap is reached; the server rejects over-cap
RSVPs and edits with 409 as a race backstop.

Also threads a safeNext-validated ?next= param through the login and
name-setup flow so guests land back on the event after signing in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:36:57 -04:00
parent 2aa3abf035
commit d005080aca
13 changed files with 368 additions and 44 deletions
+126 -19
View File
@@ -24,8 +24,18 @@ const (
maxRsvps = 200
maxClaims = 50
maxMaxClaims = 50
maxAttendeeCap = 1000
maxPlusOne = 10
)
func plural(n int64) string {
if n == 1 {
return ""
}
return "s"
}
func sanitize(s string, maxLen int) string {
s = strings.TrimSpace(s)
if len(s) > maxLen {
@@ -73,10 +83,22 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
return
}
// Capped events require login to RSVP, so the cap is only available when auth is on.
var attendeeCap int64
if s.features.Auth {
if v, err := strconv.ParseInt(r.FormValue("attendee_cap"), 10, 64); err == nil && v > 0 {
attendeeCap = v
}
if attendeeCap > maxAttendeeCap {
attendeeCap = maxAttendeeCap
}
}
slug := randomSlug()
token := randomToken()
event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{
Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description,
AttendeeCap: attendeeCap,
})
if err != nil {
log.Printf("create event: %v", err)
@@ -154,6 +176,10 @@ type EventPageData struct {
DescriptionHTML template.HTML
User *db.User
AuthEnabled bool
Capped bool
SpotsLeft int64
CapFull bool
PlusOneMax int64
}
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
@@ -217,6 +243,22 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
descHTML = RenderMarkdown(event.Description)
}
capped := s.features.Auth && event.AttendeeCap > 0
var spotsLeft int64
plusOneMax := int64(maxPlusOne)
if capped {
spotsLeft = event.AttendeeCap - totalGoing
if spotsLeft < 0 {
spotsLeft = 0
}
if spotsLeft-1 < plusOneMax {
plusOneMax = spotsLeft - 1
}
if plusOneMax < 0 {
plusOneMax = 0
}
}
return &EventPageData{
Event: event,
Slots: slotViews,
@@ -228,6 +270,10 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
DescriptionHTML: descHTML,
User: s.currentUser(r),
AuthEnabled: s.features.Auth,
Capped: capped,
SpotsLeft: spotsLeft,
CapFull: capped && totalGoing >= event.AttendeeCap,
PlusOneMax: plusOneMax,
}, nil
}
@@ -324,6 +370,38 @@ 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
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{
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
}
}
}
// Optional slot claim
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
@@ -363,27 +441,33 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
}
// Create RSVP (deduped — skip if already on the list)
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
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 !alreadyRsvped {
var userID sql.NullInt64
if user != nil {
userID = sql.NullInt64{Int64: user.ID, Valid: true}
}
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,
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
EventID: event.ID, Name: name,
})
if err != nil {
log.Printf("create rsvp: %v", err)
http.Error(w, "Failed", http.StatusInternalServerError)
return
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 err != nil {
log.Printf("create rsvp: %v", err)
http.Error(w, "Failed", http.StatusInternalServerError)
return
}
}
}
@@ -442,6 +526,29 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
plusOne = 10
}
event, err := s.q.GetEventBySlug(r.Context(), slug)
if err != nil {
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
}
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 {
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,
})