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
+93 -11
View File
@@ -54,7 +54,7 @@ func autoRsvpPlusOne(ctx context.Context, q *db.Queries, event db.Event, name st
})
if err == sql.ErrNoRows {
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, Status: "yes",
})
return err
}
@@ -162,10 +162,10 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
event := createTestEvent(t, q)
// Insert duplicate RSVPs directly
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie", PlusOne: 0, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0, Status: "yes"})
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 4 {
@@ -187,14 +187,14 @@ func TestUpdateRsvp(t *testing.T) {
event := createTestEvent(t, q)
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice", Note: "hi", PlusOne: 1,
EventID: event.ID, Name: "Alice", Note: "hi", PlusOne: 1, Status: "yes",
})
if err != nil {
t.Fatal(err)
}
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
Name: "Alicia", Note: "updated", PlusOne: 3, ID: rsvp.ID,
Name: "Alicia", Note: "updated", PlusOne: 3, Status: "yes", ID: rsvp.ID,
})
if err != nil {
t.Fatal(err)
@@ -252,8 +252,8 @@ func TestCountGoing(t *testing.T) {
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})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 0, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2, Status: "yes"})
going, err = q.CountGoing(ctx, event.ID)
if err != nil {
@@ -282,7 +282,7 @@ func TestGetRsvpByUser(t *testing.T) {
}
q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice", PlusOne: 1,
EventID: event.ID, Name: "Alice", PlusOne: 1, Status: "yes",
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
})
@@ -311,7 +311,7 @@ func TestAttendeeCapArithmetic(t *testing.T) {
t.Fatalf("expected AttendeeCap=3, got %d", event.AttendeeCap)
}
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1, Status: "yes"})
going, _ := q.CountGoing(ctx, event.ID)
// Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap.
@@ -341,3 +341,85 @@ func TestRsvpPlusOne(t *testing.T) {
t.Fatalf("expected PlusOne=2, got %d", rsvps[0].PlusOne)
}
}
func TestRsvpNoExcludedFromCountGoing(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 0, Status: "no"})
going, err := q.CountGoing(ctx, event.ID)
if err != nil {
t.Fatal(err)
}
if going != 2 {
t.Fatalf("expected 2 going (no's excluded), got %d", going)
}
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 2 {
t.Fatalf("expected 2 RSVPs listed (including no), got %d", len(rsvps))
}
}
func TestSwitchAnswerToNo(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice", PlusOne: 2, Status: "yes",
})
if err != nil {
t.Fatal(err)
}
// Same update handleRsvp performs when re-answering with "no".
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
Name: "Alice", Note: "", PlusOne: 0, Status: "no", ID: rsvp.ID,
})
if err != nil {
t.Fatal(err)
}
going, _ := q.CountGoing(ctx, event.ID)
if going != 0 {
t.Fatalf("expected 0 going after switching to no, got %d", going)
}
updated, _ := q.GetRsvp(ctx, rsvp.ID)
if updated.Status != "no" {
t.Fatalf("expected status no, got %s", updated.Status)
}
}
func TestNoFreesClaimedSlots(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
slot, err := q.CreateSlot(ctx, db.CreateSlotParams{
EventID: event.ID, Name: "Drinks", MaxClaims: 2,
})
if err != nil {
t.Fatal(err)
}
if err := autoRsvp(ctx, q, event, "Alice", "seltzer", &slot.ID); err != nil {
t.Fatal(err)
}
// Same cleanup handleRsvp performs on a "no" answer (case-insensitive).
err = q.DeleteClaimsByEventAndName(ctx, db.DeleteClaimsByEventAndNameParams{
EventID: event.ID, Name: "alice",
})
if err != nil {
t.Fatal(err)
}
count, _ := q.CountClaimsBySlot(ctx, slot.ID)
if count != 0 {
t.Fatalf("expected claim freed after no, got %d claims", count)
}
}