diff --git a/CLAUDE.md b/CLAUDE.md index 1a53ed2..69d2a02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,4 +70,4 @@ Dynamic PNG generation at `/e/{slug}/og.png` using Go's `image` package with a c ### Auth Model -No user auth. Admin access is via secret `admin_token` in the URL. Claims/RSVPs are open to anyone with the event link. +Email-code login behind the `BBQ_FEATURES=auth` flag. When auth is on, RSVP answers (yes or no) require login and are deduped one-per-user; when off, everything is open like a shared Google Sheet. RSVPs have a `status` column (`yes`/`no`) — no's don't count toward Going and free any claimed slots. Admin access is via secret `admin_token` in the URL (plus event ownership when auth is on). diff --git a/db/models.go b/db/models.go index 970dd7b..200a81b 100644 --- a/db/models.go +++ b/db/models.go @@ -37,6 +37,7 @@ type Rsvp struct { Name string Note string PlusOne int64 + Status string UserID sql.NullInt64 CreatedAt time.Time } diff --git a/db/queries.sql b/db/queries.sql index 6359f24..cc9eaff 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -64,12 +64,16 @@ DELETE FROM claims WHERE id = ?; -- name: CountClaimsBySlot :one SELECT COUNT(*) FROM claims WHERE slot_id = ?; +-- name: DeleteClaimsByEventAndName :exec +DELETE FROM claims WHERE slot_id IN (SELECT id FROM slots WHERE event_id = ?) +AND claims.name = ? COLLATE NOCASE; + -- name: ListRsvps :many SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at; -- name: CreateRsvp :one -INSERT INTO rsvps (event_id, name, note, plus_one, user_id) -VALUES (?, ?, ?, ?, ?) +INSERT INTO rsvps (event_id, name, note, plus_one, status, user_id) +VALUES (?, ?, ?, ?, ?, ?) RETURNING *; -- name: DeleteRsvp :exec @@ -85,7 +89,7 @@ SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1; SELECT * FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1; -- name: CountGoing :one -SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ?; +SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ? AND status = 'yes'; -- name: DeleteDuplicateRsvps :exec DELETE FROM rsvps WHERE id NOT IN ( @@ -140,7 +144,7 @@ UPDATE events SET user_id = ? WHERE id = ?; SELECT * FROM rsvps WHERE id = ?; -- name: UpdateRsvp :exec -UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?; +UPDATE rsvps SET name = ?, note = ?, plus_one = ?, status = ? WHERE id = ?; -- name: CreateSmsConsent :exec INSERT INTO sms_consents (phone, ip_address, user_agent) VALUES (?, ?, ?); diff --git a/db/queries.sql.go b/db/queries.sql.go index 4a42214..61d4cb8 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -23,7 +23,7 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e } const countGoing = `-- name: CountGoing :one -SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ? +SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ? AND status = 'yes' ` func (q *Queries) CountGoing(ctx context.Context, eventID int64) (int64, error) { @@ -115,9 +115,9 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event } const createRsvp = `-- name: CreateRsvp :one -INSERT INTO rsvps (event_id, name, note, plus_one, user_id) -VALUES (?, ?, ?, ?, ?) -RETURNING id, event_id, name, note, plus_one, user_id, created_at +INSERT INTO rsvps (event_id, name, note, plus_one, status, user_id) +VALUES (?, ?, ?, ?, ?, ?) +RETURNING id, event_id, name, note, plus_one, status, user_id, created_at ` type CreateRsvpParams struct { @@ -125,6 +125,7 @@ type CreateRsvpParams struct { Name string Note string PlusOne int64 + Status string UserID sql.NullInt64 } @@ -134,6 +135,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e arg.Name, arg.Note, arg.PlusOne, + arg.Status, arg.UserID, ) var i Rsvp @@ -143,6 +145,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e &i.Name, &i.Note, &i.PlusOne, + &i.Status, &i.UserID, &i.CreatedAt, ) @@ -271,6 +274,21 @@ func (q *Queries) DeleteClaim(ctx context.Context, id int64) error { return err } +const deleteClaimsByEventAndName = `-- name: DeleteClaimsByEventAndName :exec +DELETE FROM claims WHERE slot_id IN (SELECT id FROM slots WHERE event_id = ?) +AND claims.name = ? COLLATE NOCASE +` + +type DeleteClaimsByEventAndNameParams struct { + EventID int64 + Name string +} + +func (q *Queries) DeleteClaimsByEventAndName(ctx context.Context, arg DeleteClaimsByEventAndNameParams) error { + _, err := q.db.ExecContext(ctx, deleteClaimsByEventAndName, arg.EventID, arg.Name) + return err +} + const deleteDuplicateRsvps = `-- name: DeleteDuplicateRsvps :exec DELETE FROM rsvps WHERE id NOT IN ( SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE @@ -391,7 +409,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error } const getRsvp = `-- name: GetRsvp :one -SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE id = ? +SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE id = ? ` func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) { @@ -403,6 +421,7 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) { &i.Name, &i.Note, &i.PlusOne, + &i.Status, &i.UserID, &i.CreatedAt, ) @@ -410,7 +429,7 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) { } const getRsvpByName = `-- name: GetRsvpByName :one -SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1 +SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1 ` type GetRsvpByNameParams struct { @@ -427,6 +446,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R &i.Name, &i.Note, &i.PlusOne, + &i.Status, &i.UserID, &i.CreatedAt, ) @@ -434,7 +454,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R } const getRsvpByUser = `-- name: GetRsvpByUser :one -SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1 +SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1 ` type GetRsvpByUserParams struct { @@ -451,6 +471,7 @@ func (q *Queries) GetRsvpByUser(ctx context.Context, arg GetRsvpByUserParams) (R &i.Name, &i.Note, &i.PlusOne, + &i.Status, &i.UserID, &i.CreatedAt, ) @@ -653,7 +674,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([ } const listRsvps = `-- name: ListRsvps :many -SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at +SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at ` func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) { @@ -671,6 +692,7 @@ func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) &i.Name, &i.Note, &i.PlusOne, + &i.Status, &i.UserID, &i.CreatedAt, ); err != nil { @@ -811,13 +833,14 @@ func (q *Queries) UpdateEventLocation(ctx context.Context, arg UpdateEventLocati } const updateRsvp = `-- name: UpdateRsvp :exec -UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ? +UPDATE rsvps SET name = ?, note = ?, plus_one = ?, status = ? WHERE id = ? ` type UpdateRsvpParams struct { Name string Note string PlusOne int64 + Status string ID int64 } @@ -826,6 +849,7 @@ func (q *Queries) UpdateRsvp(ctx context.Context, arg UpdateRsvpParams) error { arg.Name, arg.Note, arg.PlusOne, + arg.Status, arg.ID, ) return err diff --git a/handlers.go b/handlers.go index 3d99096..6b71cd1 100644 --- a/handlers.go +++ b/handlers.go @@ -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) diff --git a/handlers_test.go b/handlers_test.go index e392c9f..888f698 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -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) + } +} diff --git a/migrate.go b/migrate.go index 10e039f..bc5162f 100644 --- a/migrate.go +++ b/migrate.go @@ -36,6 +36,8 @@ func runMigrations(database *sql.DB) { // Attendee cap (0 = uncapped); capped events require login to RSVP. `ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE rsvps ADD COLUMN user_id INTEGER REFERENCES users(id)`, + // RSVPs can now be a yes or a no. + `ALTER TABLE rsvps ADD COLUMN status TEXT NOT NULL DEFAULT 'yes'`, } for _, m := range migrations { _, err := database.Exec(m) diff --git a/schema.sql b/schema.sql index 0620de2..bf35287 100644 --- a/schema.sql +++ b/schema.sql @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS rsvps ( name TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', plus_one INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'yes', -- 'yes' or 'no' user_id INTEGER REFERENCES users(id), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/templates/edit-rsvp.html b/templates/edit-rsvp.html index d78f77a..ea96dcf 100644 --- a/templates/edit-rsvp.html +++ b/templates/edit-rsvp.html @@ -9,6 +9,13 @@ +
+ + +
diff --git a/templates/event.html b/templates/event.html index 6d3a224..1e2c050 100644 --- a/templates/event.html +++ b/templates/event.html @@ -41,25 +41,25 @@
Sign up
-{{if and .Capped .CapFull}} -
-
Event is full
-

- All {{.Event.AttendeeCap}} spots are taken. -

-
-{{else if and .Capped (not .User)}} +{{if and .AuthEnabled (not .User)}}
Log in to RSVP

- This event has limited spots ({{.SpotsLeft}} left), so RSVPs require an account. + {{if .Capped}}This event has limited spots ({{.SpotsLeft}} left), so RSVPs{{else}}RSVPs{{end}} require an account.

Log in to RSVP ↗
{{else}}
-
I'm coming →
+ {{if .CapFull}} +
Event is full
+

+ All {{.Event.AttendeeCap}} spots are taken. You can still let them know you can't make it. +

+ {{else}} +
Are you coming?
+ {{end}}
Your name
+ {{if not .CapFull}} {{if .Slots}}
@@ -83,11 +84,17 @@
+ {{end}}
- +
+ {{if not .CapFull}} + + {{end}} + +
{{end}} diff --git a/templates/layout.html b/templates/layout.html index 16bf62d..98f604f 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -286,6 +286,13 @@ box-shadow: 4px 4px 0 var(--sage); transform: translate(-2px, -2px); } + .btn-no { + background: var(--cream); + color: var(--ink); + } + .btn-no:hover { + box-shadow: 4px 4px 0 var(--peach); + } .progress-bar { height: 4px; background: #e8e8e8; diff --git a/templates/slots.html b/templates/slots.html index 092a2c2..eb5f4b2 100644 --- a/templates/slots.html +++ b/templates/slots.html @@ -37,11 +37,11 @@ {{if .GoingList}} {{range .GoingList}} - {{if gt .RsvpID 0}}{{.Name}}{{else}}{{.Name}}{{end}}{{if gt .PlusOne 0}} +{{.PlusOne}}{{end}}{{if .Note}} ({{.Note}}){{end}} - {{if gt .RsvpID 0}} + {{if and (gt .RsvpID 0) $.CanEditRsvps}} + {{end}} + + {{end}} + +{{end}} {{end}} {{define "slots.html"}}{{template "slots-inner" .}}{{end}}