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
+1 -1
View File
@@ -70,4 +70,4 @@ Dynamic PNG generation at `/e/{slug}/og.png` using Go's `image` package with a c
### Auth Model ### 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).
+1
View File
@@ -37,6 +37,7 @@ type Rsvp struct {
Name string Name string
Note string Note string
PlusOne int64 PlusOne int64
Status string
UserID sql.NullInt64 UserID sql.NullInt64
CreatedAt time.Time CreatedAt time.Time
} }
+8 -4
View File
@@ -64,12 +64,16 @@ DELETE FROM claims WHERE id = ?;
-- name: CountClaimsBySlot :one -- name: CountClaimsBySlot :one
SELECT COUNT(*) FROM claims WHERE slot_id = ?; 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 -- name: ListRsvps :many
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at; SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
-- name: CreateRsvp :one -- name: CreateRsvp :one
INSERT INTO rsvps (event_id, name, note, plus_one, user_id) INSERT INTO rsvps (event_id, name, note, plus_one, status, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
RETURNING *; RETURNING *;
-- name: DeleteRsvp :exec -- 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; SELECT * FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1;
-- name: CountGoing :one -- 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 -- name: DeleteDuplicateRsvps :exec
DELETE FROM rsvps WHERE id NOT IN ( DELETE FROM rsvps WHERE id NOT IN (
@@ -140,7 +144,7 @@ UPDATE events SET user_id = ? WHERE id = ?;
SELECT * FROM rsvps WHERE id = ?; SELECT * FROM rsvps WHERE id = ?;
-- name: UpdateRsvp :exec -- 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 -- name: CreateSmsConsent :exec
INSERT INTO sms_consents (phone, ip_address, user_agent) VALUES (?, ?, ?); INSERT INTO sms_consents (phone, ip_address, user_agent) VALUES (?, ?, ?);
+33 -9
View File
@@ -23,7 +23,7 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e
} }
const countGoing = `-- name: CountGoing :one 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) { 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 const createRsvp = `-- name: CreateRsvp :one
INSERT INTO rsvps (event_id, name, note, plus_one, user_id) INSERT INTO rsvps (event_id, name, note, plus_one, status, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, event_id, name, note, plus_one, user_id, created_at RETURNING id, event_id, name, note, plus_one, status, user_id, created_at
` `
type CreateRsvpParams struct { type CreateRsvpParams struct {
@@ -125,6 +125,7 @@ type CreateRsvpParams struct {
Name string Name string
Note string Note string
PlusOne int64 PlusOne int64
Status string
UserID sql.NullInt64 UserID sql.NullInt64
} }
@@ -134,6 +135,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e
arg.Name, arg.Name,
arg.Note, arg.Note,
arg.PlusOne, arg.PlusOne,
arg.Status,
arg.UserID, arg.UserID,
) )
var i Rsvp var i Rsvp
@@ -143,6 +145,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e
&i.Name, &i.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.Status,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -271,6 +274,21 @@ func (q *Queries) DeleteClaim(ctx context.Context, id int64) error {
return err 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 const deleteDuplicateRsvps = `-- name: DeleteDuplicateRsvps :exec
DELETE FROM rsvps WHERE id NOT IN ( DELETE FROM rsvps WHERE id NOT IN (
SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE 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 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) { 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.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.Status,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -410,7 +429,7 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
} }
const getRsvpByName = `-- name: GetRsvpByName :one 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 { type GetRsvpByNameParams struct {
@@ -427,6 +446,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
&i.Name, &i.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.Status,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -434,7 +454,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
} }
const getRsvpByUser = `-- name: GetRsvpByUser :one 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 { type GetRsvpByUserParams struct {
@@ -451,6 +471,7 @@ func (q *Queries) GetRsvpByUser(ctx context.Context, arg GetRsvpByUserParams) (R
&i.Name, &i.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.Status,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -653,7 +674,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([
} }
const listRsvps = `-- name: ListRsvps :many 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) { 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.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.Status,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
); err != nil { ); err != nil {
@@ -811,13 +833,14 @@ func (q *Queries) UpdateEventLocation(ctx context.Context, arg UpdateEventLocati
} }
const updateRsvp = `-- name: UpdateRsvp :exec 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 { type UpdateRsvpParams struct {
Name string Name string
Note string Note string
PlusOne int64 PlusOne int64
Status string
ID int64 ID int64
} }
@@ -826,6 +849,7 @@ func (q *Queries) UpdateRsvp(ctx context.Context, arg UpdateRsvpParams) error {
arg.Name, arg.Name,
arg.Note, arg.Note,
arg.PlusOne, arg.PlusOne,
arg.Status,
arg.ID, arg.ID,
) )
return err return err
+147 -69
View File
@@ -170,12 +170,14 @@ type EventPageData struct {
Slots []SlotView Slots []SlotView
Rsvps []db.Rsvp Rsvps []db.Rsvp
GoingList []GoingPerson GoingList []GoingPerson
NotGoingList []GoingPerson
TotalGoing int64 TotalGoing int64
IsAdmin bool IsAdmin bool
BaseURL string BaseURL string
DescriptionHTML template.HTML DescriptionHTML template.HTML
User *db.User User *db.User
AuthEnabled bool AuthEnabled bool
CanEditRsvps bool
Capped bool Capped bool
SpotsLeft int64 SpotsLeft int64
CapFull bool CapFull bool
@@ -228,14 +230,15 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
return nil, err return nil, err
} }
var totalGoing int64 var totalGoing int64
var goingList, notGoingList []GoingPerson
for _, r := range rsvps { 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 totalGoing += 1 + r.PlusOne
} goingList = append(goingList, p)
// 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})
} }
var descHTML template.HTML 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{ return &EventPageData{
Event: event, Event: event,
Slots: slotViews, Slots: slotViews,
Rsvps: rsvps, Rsvps: rsvps,
GoingList: goingList, GoingList: goingList,
NotGoingList: notGoingList,
TotalGoing: totalGoing, TotalGoing: totalGoing,
IsAdmin: isAdmin, IsAdmin: isAdmin,
BaseURL: s.baseURL, BaseURL: s.baseURL,
DescriptionHTML: descHTML, DescriptionHTML: descHTML,
User: s.currentUser(r), User: user,
AuthEnabled: s.features.Auth, AuthEnabled: s.features.Auth,
CanEditRsvps: isAdmin || !s.features.Auth || user != nil,
Capped: capped, Capped: capped,
SpotsLeft: spotsLeft, SpotsLeft: spotsLeft,
CapFull: capped && totalGoing >= event.AttendeeCap, CapFull: capped && totalGoing >= event.AttendeeCap,
@@ -356,12 +362,19 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
return return
} }
note := sanitize(r.FormValue("note"), maxNoteLen) note := sanitize(r.FormValue("note"), maxNoteLen)
status := "yes"
if r.FormValue("status") == "no" {
status = "no"
}
plusOne := int64(0) plusOne := int64(0)
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 { if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
plusOne = v plusOne = v
} }
if plusOne > 10 { if plusOne > maxPlusOne {
plusOne = 10 plusOne = maxPlusOne
}
if status == "no" {
plusOne = 0
} }
event, err := s.q.GetEventBySlug(r.Context(), slug) event, err := s.q.GetEventBySlug(r.Context(), slug)
@@ -370,40 +383,56 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
return return
} }
// Capped events require login and enforce the cap on total people going. // Answering — yes or no — requires login when auth is enabled.
capped := s.features.Auth && event.AttendeeCap > 0
user := s.currentUser(r) user := s.currentUser(r)
alreadyRsvped := false if s.features.Auth && user == nil {
if capped { http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
if user == nil { return
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.
_, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{ var existing *db.Rsvp
if user != nil {
if rv, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
EventID: event.ID, EventID: event.ID,
UserID: sql.NullInt64{Int64: user.ID, Valid: true}, UserID: sql.NullInt64{Int64: user.ID, Valid: true},
}) }); err == nil {
alreadyRsvped = err == nil existing = &rv
if !alreadyRsvped { }
going, err := s.q.CountGoing(r.Context(), event.ID) }
if err != nil { if existing == nil {
http.Error(w, "error", http.StatusInternalServerError) if rv, err := s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
return EventID: event.ID, Name: name,
} }); err == nil {
if going+1+plusOne > event.AttendeeCap { existing = &rv
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 // Capped events enforce the cap on total people going.
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" { 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) slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
if err != nil { if err != nil {
http.Error(w, "Invalid slot", http.StatusBadRequest) 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) // Record the answer: update the existing RSVP or create a new one.
if !alreadyRsvped { 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 var userID sql.NullInt64
if user != nil { if user != nil {
userID = sql.NullInt64{Int64: user.ID, Valid: true} 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, EventID: event.ID, Name: name,
}) })
if err == sql.ErrNoRows { if existing != nil && !strings.EqualFold(existing.Name, name) {
count, err := s.q.CountRsvps(r.Context(), event.ID) s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
if err != nil { EventID: event.ID, Name: existing.Name,
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
}
} }
} }
@@ -509,6 +554,11 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
return 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.Body = http.MaxBytesReader(w, r.Body, 8*1024)
r.ParseForm() r.ParseForm()
@@ -518,12 +568,19 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
return return
} }
note := sanitize(r.FormValue("note"), maxNoteLen) note := sanitize(r.FormValue("note"), maxNoteLen)
status := "yes"
if r.FormValue("status") == "no" {
status = "no"
}
plusOne := int64(0) plusOne := int64(0)
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 { if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
plusOne = v plusOne = v
} }
if plusOne > 10 { if plusOne > maxPlusOne {
plusOne = 10 plusOne = maxPlusOne
}
if status == "no" {
plusOne = 0
} }
event, err := s.q.GetEventBySlug(r.Context(), slug) 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) http.Error(w, "Event not found", http.StatusNotFound)
return return
} }
// Raising plus_one can't exceed the attendee cap. old, err := s.q.GetRsvp(r.Context(), rsvpID)
if s.features.Auth && event.AttendeeCap > 0 { if err != nil {
old, err := s.q.GetRsvp(r.Context(), rsvpID) http.Error(w, "RSVP not found", http.StatusNotFound)
if err != nil { return
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) going, err := s.q.CountGoing(r.Context(), event.ID)
if err != nil { if err != nil {
http.Error(w, "error", http.StatusInternalServerError) http.Error(w, "error", http.StatusInternalServerError)
return 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) http.Error(w, "Not enough spots left", http.StatusConflict)
return return
} }
} }
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{ 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 { if err != nil {
http.Error(w, "Failed to update", http.StatusInternalServerError) http.Error(w, "Failed to update", http.StatusInternalServerError)
return 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) s.notify(slug)
w.Header().Set("HX-Trigger", "closeModal") w.Header().Set("HX-Trigger", "closeModal")
@@ -576,6 +649,11 @@ func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) {
return 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.q.DeleteRsvp(r.Context(), rsvpID)
s.notify(slug) s.notify(slug)
+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 { if err == sql.ErrNoRows {
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{ _, 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 return err
} }
@@ -162,10 +162,10 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
event := createTestEvent(t, q) event := createTestEvent(t, q)
// Insert duplicate RSVPs directly // 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, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", 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}) 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}) q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0, Status: "yes"})
rsvps, _ := q.ListRsvps(ctx, event.ID) rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 4 { if len(rsvps) != 4 {
@@ -187,14 +187,14 @@ func TestUpdateRsvp(t *testing.T) {
event := createTestEvent(t, q) event := createTestEvent(t, q)
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{ 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{ 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -252,8 +252,8 @@ func TestCountGoing(t *testing.T) {
t.Fatalf("expected 0 going on empty event, got %d", going) 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: "Alice", PlusOne: 0, Status: "yes"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2}) q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2, Status: "yes"})
going, err = q.CountGoing(ctx, event.ID) going, err = q.CountGoing(ctx, event.ID)
if err != nil { if err != nil {
@@ -282,7 +282,7 @@ func TestGetRsvpByUser(t *testing.T) {
} }
q.CreateRsvp(ctx, db.CreateRsvpParams{ 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}, 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) 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) going, _ := q.CountGoing(ctx, event.ID)
// Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap. // 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) 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)
}
}
+2
View File
@@ -36,6 +36,8 @@ func runMigrations(database *sql.DB) {
// Attendee cap (0 = uncapped); capped events require login to RSVP. // Attendee cap (0 = uncapped); capped events require login to RSVP.
`ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE rsvps ADD COLUMN user_id INTEGER REFERENCES users(id)`, `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 { for _, m := range migrations {
_, err := database.Exec(m) _, err := database.Exec(m)
+1
View File
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
name TEXT NOT NULL, name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
plus_one INTEGER NOT NULL DEFAULT 0, plus_one INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'yes', -- 'yes' or 'no'
user_id INTEGER REFERENCES users(id), user_id INTEGER REFERENCES users(id),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
+7
View File
@@ -9,6 +9,13 @@
<label>Your name</label> <label>Your name</label>
<input type="text" name="name" value="{{.Rsvp.Name}}" required> <input type="text" name="name" value="{{.Rsvp.Name}}" required>
</div> </div>
<div class="form-row">
<label>Coming?</label>
<select name="status">
<option value="yes" {{if ne .Rsvp.Status "no"}}selected{{end}}>Yes, I'm going</option>
<option value="no" {{if eq .Rsvp.Status "no"}}selected{{end}}>Can't make it</option>
</select>
</div>
<div class="form-row"> <div class="form-row">
<label>Bringing anyone?</label> <label>Bringing anyone?</label>
<input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10"> <input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10">
+18 -11
View File
@@ -41,25 +41,25 @@
<div class="section-label" style="margin-top:40px">Sign up</div> <div class="section-label" style="margin-top:40px">Sign up</div>
{{if and .Capped .CapFull}} {{if and .AuthEnabled (not .User)}}
<div class="claim-form-wrapper">
<div class="form-title">Event is full</div>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;">
All {{.Event.AttendeeCap}} spots are taken.
</p>
</div>
{{else if and .Capped (not .User)}}
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
<div class="form-title">Log in to RSVP</div> <div class="form-title">Log in to RSVP</div>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;"> <p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;">
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.
</p> </p>
<a class="btn-submit" style="display:inline-block;text-decoration:none;text-align:center;" <a class="btn-submit" style="display:inline-block;text-decoration:none;text-align:center;"
href="/login?next=/e/{{.Event.Slug}}">Log in to RSVP &#8599;</a> href="/login?next=/e/{{.Event.Slug}}">Log in to RSVP &#8599;</a>
</div> </div>
{{else}} {{else}}
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
<div class="form-title">I'm coming &#8594;</div> {{if .CapFull}}
<div class="form-title">Event is full</div>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;">
All {{.Event.AttendeeCap}} spots are taken. You can still let them know you can't make it.
</p>
{{else}}
<div class="form-title">Are you coming?</div>
{{end}}
<form hx-post="/e/{{.Event.Slug}}/rsvp" <form hx-post="/e/{{.Event.Slug}}/rsvp"
hx-target="#slots-container" hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s" hx-swap="innerHTML settle:0.1s"
@@ -68,6 +68,7 @@
<label>Your name</label> <label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}"> <input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
</div> </div>
{{if not .CapFull}}
{{if .Slots}} {{if .Slots}}
<div class="form-row"> <div class="form-row">
<label>Bringing something?</label> <label>Bringing something?</label>
@@ -83,11 +84,17 @@
<label>Bringing anyone?</label> <label>Bringing anyone?</label>
<input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}"> <input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}">
</div> </div>
{{end}}
<div class="form-row" id="claim-note"> <div class="form-row" id="claim-note">
<label>Note (optional)</label> <label>Note (optional)</label>
<input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc."> <input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc.">
</div> </div>
<button class="btn-submit" type="submit">Count me in &#8599;</button> <div style="display:flex;gap:10px">
{{if not .CapFull}}
<button class="btn-submit" type="submit" name="status" value="yes">Count me in &#8599;</button>
{{end}}
<button class="btn-submit btn-no" type="submit" name="status" value="no">Can't make it</button>
</div>
</form> </form>
</div> </div>
{{end}} {{end}}
+7
View File
@@ -286,6 +286,13 @@
box-shadow: 4px 4px 0 var(--sage); box-shadow: 4px 4px 0 var(--sage);
transform: translate(-2px, -2px); transform: translate(-2px, -2px);
} }
.btn-no {
background: var(--cream);
color: var(--ink);
}
.btn-no:hover {
box-shadow: 4px 4px 0 var(--peach);
}
.progress-bar { .progress-bar {
height: 4px; height: 4px;
background: #e8e8e8; background: #e8e8e8;
+23 -2
View File
@@ -37,11 +37,11 @@
{{if .GoingList}} {{if .GoingList}}
{{range .GoingList}} {{range .GoingList}}
<span class="claim-chip"> <span class="claim-chip">
{{if gt .RsvpID 0}}<span class="rsvp-name-link" {{if and (gt .RsvpID 0) $.CanEditRsvps}}<span class="rsvp-name-link"
hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit" hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit"
hx-target="#edit-modal" hx-target="#edit-modal"
hx-swap="innerHTML">{{.Name}}</span>{{else}}{{.Name}}{{end}}{{if gt .PlusOne 0}} +{{.PlusOne}}{{end}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}} hx-swap="innerHTML">{{.Name}}</span>{{else}}{{.Name}}{{end}}{{if gt .PlusOne 0}} +{{.PlusOne}}{{end}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
{{if gt .RsvpID 0}} {{if and (gt .RsvpID 0) $.CanEditRsvps}}
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}" <button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
hx-target="#slots-container" hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s" hx-swap="innerHTML settle:0.1s"
@@ -54,6 +54,27 @@
<span class="nobody">no one yet</span> <span class="nobody">no one yet</span>
{{end}} {{end}}
</div> </div>
{{if .NotGoingList}}
<div class="section-label">Can't make it ({{len .NotGoingList}})</div>
<div class="rsvp-list">
{{range .NotGoingList}}
<span class="claim-chip" style="opacity:0.6">
{{if $.CanEditRsvps}}<span class="rsvp-name-link"
hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit"
hx-target="#edit-modal"
hx-swap="innerHTML">{{.Name}}</span>{{else}}{{.Name}}{{end}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
{{if $.CanEditRsvps}}
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-confirm="Remove {{.Name}}?"
title="Remove">&#215;</button>
{{end}}
</span>
{{end}}
</div>
{{end}}
{{end}} {{end}}
{{define "slots.html"}}{{template "slots-inner" .}}{{end}} {{define "slots.html"}}{{template "slots-inner" .}}{{end}}