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:
+126
-19
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user