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
+85
View File
@@ -215,6 +215,91 @@ func TestUpdateRsvp(t *testing.T) {
}
}
func TestCountGoing(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
going, err := q.CountGoing(ctx, event.ID)
if err != nil {
t.Fatal(err)
}
if going != 0 {
t.Fatalf("expected 0 going on empty event, got %d", going)
}
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2})
going, err = q.CountGoing(ctx, event.ID)
if err != nil {
t.Fatal(err)
}
if going != 4 {
t.Fatalf("expected 4 going (2 RSVPs + 2 plus-ones), got %d", going)
}
}
func TestGetRsvpByUser(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
user, err := q.CreateUserByEmail(ctx, sql.NullString{String: "alice@example.com", Valid: true})
if err != nil {
t.Fatal(err)
}
_, err = q.GetRsvpByUser(ctx, db.GetRsvpByUserParams{
EventID: event.ID, UserID: sql.NullInt64{Int64: user.ID, Valid: true},
})
if err != sql.ErrNoRows {
t.Fatalf("expected ErrNoRows before RSVP, got %v", err)
}
q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice", PlusOne: 1,
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
})
rsvp, err := q.GetRsvpByUser(ctx, db.GetRsvpByUserParams{
EventID: event.ID, UserID: sql.NullInt64{Int64: user.ID, Valid: true},
})
if err != nil {
t.Fatal(err)
}
if rsvp.Name != "Alice" {
t.Fatalf("expected RSVP name Alice, got %s", rsvp.Name)
}
}
func TestAttendeeCapArithmetic(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event, err := q.CreateEvent(ctx, db.CreateEventParams{
Slug: "capped", Title: "Capped Event", Date: "June 1", Time: "2pm",
Location: "Park", AdminToken: "tok456", AttendeeCap: 3,
})
if err != nil {
t.Fatal(err)
}
if event.AttendeeCap != 3 {
t.Fatalf("expected AttendeeCap=3, got %d", event.AttendeeCap)
}
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1})
going, _ := q.CountGoing(ctx, event.ID)
// Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap.
if going+1+1 <= event.AttendeeCap {
t.Fatalf("expected RSVP +1 to exceed cap (going=%d, cap=%d)", going, event.AttendeeCap)
}
// A solo RSVP still fits.
if going+1+0 > event.AttendeeCap {
t.Fatalf("expected solo RSVP to fit (going=%d, cap=%d)", going, event.AttendeeCap)
}
}
func TestRsvpPlusOne(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()