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:
@@ -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).
|
||||
|
||||
@@ -37,6 +37,7 @@ type Rsvp struct {
|
||||
Name string
|
||||
Note string
|
||||
PlusOne int64
|
||||
Status string
|
||||
UserID sql.NullInt64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
+8
-4
@@ -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 (?, ?, ?);
|
||||
|
||||
+33
-9
@@ -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
|
||||
|
||||
+117
-39
@@ -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 {
|
||||
totalGoing += 1 + r.PlusOne
|
||||
p := GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne}
|
||||
if r.Status == "no" {
|
||||
notGoingList = append(notGoingList, p)
|
||||
continue
|
||||
}
|
||||
|
||||
// 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})
|
||||
totalGoing += 1 + 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,28 +383,45 @@ 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 {
|
||||
if s.features.Auth && user == nil {
|
||||
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
|
||||
|
||||
// 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 {
|
||||
}); 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if going+1+plusOne > event.AttendeeCap {
|
||||
left := event.AttendeeCap - going
|
||||
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 {
|
||||
@@ -400,10 +430,9 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional slot claim
|
||||
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
|
||||
// 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,16 +469,17 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create RSVP (deduped — skip if already on the list)
|
||||
if !alreadyRsvped {
|
||||
var userID sql.NullInt64
|
||||
if user != nil {
|
||||
userID = sql.NullInt64{Int64: user.ID, Valid: true}
|
||||
}
|
||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: name,
|
||||
// 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 == sql.ErrNoRows {
|
||||
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)
|
||||
@@ -460,8 +490,12 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var userID sql.NullInt64
|
||||
if user != nil {
|
||||
userID = sql.NullInt64{Int64: user.ID, Valid: true}
|
||||
}
|
||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, UserID: userID,
|
||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, Status: status, UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create rsvp: %v", err)
|
||||
@@ -469,6 +503,17 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
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 existing != nil && !strings.EqualFold(existing.Name, name) {
|
||||
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
|
||||
EventID: event.ID, Name: existing.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
@@ -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
|
||||
}
|
||||
// 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)
|
||||
|
||||
|
||||
+93
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<label>Your name</label>
|
||||
<input type="text" name="name" value="{{.Rsvp.Name}}" required>
|
||||
</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">
|
||||
<label>Bringing anyone?</label>
|
||||
<input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10">
|
||||
|
||||
+18
-11
@@ -41,25 +41,25 @@
|
||||
|
||||
<div class="section-label" style="margin-top:40px">Sign up</div>
|
||||
|
||||
{{if and .Capped .CapFull}}
|
||||
<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)}}
|
||||
{{if and .AuthEnabled (not .User)}}
|
||||
<div class="claim-form-wrapper">
|
||||
<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;">
|
||||
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>
|
||||
<a class="btn-submit" style="display:inline-block;text-decoration:none;text-align:center;"
|
||||
href="/login?next=/e/{{.Event.Slug}}">Log in to RSVP ↗</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="claim-form-wrapper">
|
||||
<div class="form-title">I'm coming →</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"
|
||||
hx-target="#slots-container"
|
||||
hx-swap="innerHTML settle:0.1s"
|
||||
@@ -68,6 +68,7 @@
|
||||
<label>Your name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
|
||||
</div>
|
||||
{{if not .CapFull}}
|
||||
{{if .Slots}}
|
||||
<div class="form-row">
|
||||
<label>Bringing something?</label>
|
||||
@@ -83,11 +84,17 @@
|
||||
<label>Bringing anyone?</label>
|
||||
<input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="form-row" id="claim-note">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc.">
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
||||
<div style="display:flex;gap:10px">
|
||||
{{if not .CapFull}}
|
||||
<button class="btn-submit" type="submit" name="status" value="yes">Count me in ↗</button>
|
||||
{{end}}
|
||||
<button class="btn-submit btn-no" type="submit" name="status" value="no">Can't make it</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+23
-2
@@ -37,11 +37,11 @@
|
||||
{{if .GoingList}}
|
||||
{{range .GoingList}}
|
||||
<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-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}}
|
||||
{{if gt .RsvpID 0}}
|
||||
{{if and (gt .RsvpID 0) $.CanEditRsvps}}
|
||||
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
|
||||
hx-target="#slots-container"
|
||||
hx-swap="innerHTML settle:0.1s"
|
||||
@@ -54,6 +54,27 @@
|
||||
<span class="nobody">no one yet</span>
|
||||
{{end}}
|
||||
</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">×</button>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
||||
|
||||
Reference in New Issue
Block a user