From d005080aca10f370d3179c61ae9ca73c7ddd176b Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Wed, 10 Jun 2026 19:36:57 -0400 Subject: [PATCH] Add attendee cap with required login for capped events Hosts can optionally cap total attendance (RSVPs + plus-ones) when creating an event. Capped events require guests to log in via the existing email code flow; RSVPs are tied to accounts and deduped per user. The event page shows spots left and replaces the form with a full notice when the cap is reached; the server rejects over-cap RSVPs and edits with 409 as a race backstop. Also threads a safeNext-validated ?next= param through the login and name-setup flow so guests land back on the event after signing in. Co-Authored-By: Claude Fable 5 --- auth.go | 57 +++++++++++++++-- db/models.go | 2 + db/queries.sql | 14 +++-- db/queries.sql.go | 71 +++++++++++++++++---- handlers.go | 145 +++++++++++++++++++++++++++++++++++++------ handlers_test.go | 85 +++++++++++++++++++++++++ migrate.go | 3 + schema.sql | 2 + templates/event.html | 22 ++++++- templates/home.html | 6 ++ templates/login.html | 2 + templates/name.html | 1 + templates/slots.html | 2 +- 13 files changed, 368 insertions(+), 44 deletions(-) diff --git a/auth.go b/auth.go index 10e91e4..81ace40 100644 --- a/auth.go +++ b/auth.go @@ -11,6 +11,7 @@ import ( "log" "math/big" "net/http" + "net/url" "os" "strings" "time" @@ -63,39 +64,62 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler { func (s *Server) requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.currentUser(r) == nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) + dest := "/login" + if r.Method == http.MethodGet { + dest += "?next=" + url.QueryEscape(r.URL.RequestURI()) + } + http.Redirect(w, r, dest, http.StatusSeeOther) return } next.ServeHTTP(w, r) }) } +// safeNext returns next if it is a safe local path, else "". +func safeNext(next string) string { + if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") && !strings.ContainsAny(next, "\\\r\n") { + return next + } + return "" +} + // isEmail returns true if the input looks like an email address. func isEmail(s string) bool { return strings.Contains(s, "@") } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + next := safeNext(r.URL.Query().Get("next")) if s.currentUser(r) != nil { - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + dest := "/dashboard" + if next != "" { + dest = next + } + http.Redirect(w, r, dest, http.StatusSeeOther) return } pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ "Step": "identify", + "Next": next, "AuthEnabled": true, }) } func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { + next := safeNext(r.FormValue("next")) + loginURL := "/login" + if next != "" { + loginURL += "?next=" + url.QueryEscape(next) + } raw := strings.TrimSpace(r.FormValue("identifier")) if raw == "" { - http.Redirect(w, r, "/login", http.StatusSeeOther) + http.Redirect(w, r, loginURL, http.StatusSeeOther) return } identifier := strings.ToLower(raw) if !isEmail(identifier) { - http.Redirect(w, r, "/login", http.StatusSeeOther) + http.Redirect(w, r, loginURL, http.StatusSeeOther) return } @@ -118,11 +142,13 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ "Step": "code", "Identifier": identifier, + "Next": next, "AuthEnabled": true, }) } func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) { + next := safeNext(r.FormValue("next")) identifier := strings.TrimSpace(r.FormValue("identifier")) code := strings.TrimSpace(r.FormValue("code")) @@ -134,6 +160,7 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) { pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ "Step": "code", "Identifier": identifier, + "Next": next, "Error": "Invalid or expired code. Try again.", "AuthEnabled": true, }) @@ -174,10 +201,18 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) { // If user has no name, send them to set it if user.Name == "" { - http.Redirect(w, r, "/account/name", http.StatusSeeOther) + dest := "/account/name" + if next != "" { + dest += "?next=" + url.QueryEscape(next) + } + http.Redirect(w, r, dest, http.StatusSeeOther) return } + if next != "" { + http.Redirect(w, r, next, http.StatusSeeOther) + return + } http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } @@ -215,21 +250,31 @@ func (s *Server) handleNamePage(w http.ResponseWriter, r *http.Request) { user := s.currentUser(r) pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{ "User": user, + "Next": safeNext(r.URL.Query().Get("next")), "AuthEnabled": true, }) } func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) { user := s.currentUser(r) + next := safeNext(r.FormValue("next")) name := strings.TrimSpace(r.FormValue("name")) if name == "" { - http.Redirect(w, r, "/account/name", http.StatusSeeOther) + dest := "/account/name" + if next != "" { + dest += "?next=" + url.QueryEscape(next) + } + http.Redirect(w, r, dest, http.StatusSeeOther) return } s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{ Name: name, ID: user.ID, }) + if next != "" { + http.Redirect(w, r, next, http.StatusSeeOther) + return + } http.Redirect(w, r, "/dashboard", http.StatusSeeOther) } diff --git a/db/models.go b/db/models.go index 05a787a..970dd7b 100644 --- a/db/models.go +++ b/db/models.go @@ -26,6 +26,7 @@ type Event struct { Location string AdminToken string Description string + AttendeeCap int64 UserID sql.NullInt64 CreatedAt time.Time } @@ -36,6 +37,7 @@ type Rsvp struct { Name string Note string PlusOne int64 + UserID sql.NullInt64 CreatedAt time.Time } diff --git a/db/queries.sql b/db/queries.sql index 15f958d..aebe076 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -5,8 +5,8 @@ SELECT * FROM events WHERE slug = ?; SELECT * FROM events WHERE admin_token = ?; -- name: CreateEvent :one -INSERT INTO events (slug, title, date, time, location, admin_token, description) -VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO events (slug, title, date, time, location, admin_token, description, attendee_cap) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: UpdateEventDescription :exec @@ -65,8 +65,8 @@ SELECT COUNT(*) FROM claims WHERE slot_id = ?; SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at; -- name: CreateRsvp :one -INSERT INTO rsvps (event_id, name, note, plus_one) -VALUES (?, ?, ?, ?) +INSERT INTO rsvps (event_id, name, note, plus_one, user_id) +VALUES (?, ?, ?, ?, ?) RETURNING *; -- name: DeleteRsvp :exec @@ -78,6 +78,12 @@ SELECT COUNT(*) FROM rsvps WHERE event_id = ?; -- name: GetRsvpByName :one SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1; +-- name: GetRsvpByUser :one +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 = ?; + -- name: DeleteDuplicateRsvps :exec DELETE FROM rsvps WHERE id NOT IN ( SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE diff --git a/db/queries.sql.go b/db/queries.sql.go index 267236e..158615a 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -22,6 +22,17 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e return count, err } +const countGoing = `-- name: CountGoing :one +SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ? +` + +func (q *Queries) CountGoing(ctx context.Context, eventID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countGoing, eventID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + const countRsvps = `-- name: CountRsvps :one SELECT COUNT(*) FROM rsvps WHERE event_id = ? ` @@ -59,9 +70,9 @@ func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim } const createEvent = `-- name: CreateEvent :one -INSERT INTO events (slug, title, date, time, location, admin_token, description) -VALUES (?, ?, ?, ?, ?, ?, ?) -RETURNING id, slug, title, date, time, location, admin_token, description, user_id, created_at +INSERT INTO events (slug, title, date, time, location, admin_token, description, attendee_cap) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at ` type CreateEventParams struct { @@ -72,6 +83,7 @@ type CreateEventParams struct { Location string AdminToken string Description string + AttendeeCap int64 } func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { @@ -83,6 +95,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event arg.Location, arg.AdminToken, arg.Description, + arg.AttendeeCap, ) var i Event err := row.Scan( @@ -94,6 +107,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event &i.Location, &i.AdminToken, &i.Description, + &i.AttendeeCap, &i.UserID, &i.CreatedAt, ) @@ -101,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) -VALUES (?, ?, ?, ?) -RETURNING id, event_id, name, note, plus_one, created_at +INSERT INTO rsvps (event_id, name, note, plus_one, user_id) +VALUES (?, ?, ?, ?, ?) +RETURNING id, event_id, name, note, plus_one, user_id, created_at ` type CreateRsvpParams struct { @@ -111,6 +125,7 @@ type CreateRsvpParams struct { Name string Note string PlusOne int64 + UserID sql.NullInt64 } func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) { @@ -119,6 +134,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e arg.Name, arg.Note, arg.PlusOne, + arg.UserID, ) var i Rsvp err := row.Scan( @@ -127,6 +143,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e &i.Name, &i.Note, &i.PlusOne, + &i.UserID, &i.CreatedAt, ) return i, err @@ -328,7 +345,7 @@ func (q *Queries) GetClaimByID(ctx context.Context, id int64) (Claim, error) { } const getEventByAdminToken = `-- name: GetEventByAdminToken :one -SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE admin_token = ? +SELECT id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at FROM events WHERE admin_token = ? ` func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) { @@ -343,6 +360,7 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) ( &i.Location, &i.AdminToken, &i.Description, + &i.AttendeeCap, &i.UserID, &i.CreatedAt, ) @@ -350,7 +368,7 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) ( } const getEventBySlug = `-- name: GetEventBySlug :one -SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE slug = ? +SELECT id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at FROM events WHERE slug = ? ` func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) { @@ -365,6 +383,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error &i.Location, &i.AdminToken, &i.Description, + &i.AttendeeCap, &i.UserID, &i.CreatedAt, ) @@ -372,7 +391,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, created_at FROM rsvps WHERE id = ? +SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE id = ? ` func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) { @@ -384,13 +403,14 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) { &i.Name, &i.Note, &i.PlusOne, + &i.UserID, &i.CreatedAt, ) return i, err } const getRsvpByName = `-- name: GetRsvpByName :one -SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1 +SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1 ` type GetRsvpByNameParams struct { @@ -407,6 +427,31 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R &i.Name, &i.Note, &i.PlusOne, + &i.UserID, + &i.CreatedAt, + ) + return i, err +} + +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 +` + +type GetRsvpByUserParams struct { + EventID int64 + UserID sql.NullInt64 +} + +func (q *Queries) GetRsvpByUser(ctx context.Context, arg GetRsvpByUserParams) (Rsvp, error) { + row := q.db.QueryRowContext(ctx, getRsvpByUser, arg.EventID, arg.UserID) + var i Rsvp + err := row.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Note, + &i.PlusOne, + &i.UserID, &i.CreatedAt, ) return i, err @@ -569,7 +614,7 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim, } const listEventsByUser = `-- name: ListEventsByUser :many -SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC +SELECT id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC ` func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([]Event, error) { @@ -590,6 +635,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([ &i.Location, &i.AdminToken, &i.Description, + &i.AttendeeCap, &i.UserID, &i.CreatedAt, ); err != nil { @@ -607,7 +653,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([ } const listRsvps = `-- name: ListRsvps :many -SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at +SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at ` func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) { @@ -625,6 +671,7 @@ func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) &i.Name, &i.Note, &i.PlusOne, + &i.UserID, &i.CreatedAt, ); err != nil { return nil, err diff --git a/handlers.go b/handlers.go index 8425226..e4ee40f 100644 --- a/handlers.go +++ b/handlers.go @@ -24,8 +24,18 @@ const ( maxRsvps = 200 maxClaims = 50 maxMaxClaims = 50 + + maxAttendeeCap = 1000 + maxPlusOne = 10 ) +func plural(n int64) string { + if n == 1 { + return "" + } + return "s" +} + func sanitize(s string, maxLen int) string { s = strings.TrimSpace(s) if len(s) > maxLen { @@ -73,10 +83,22 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { return } + // Capped events require login to RSVP, so the cap is only available when auth is on. + var attendeeCap int64 + if s.features.Auth { + if v, err := strconv.ParseInt(r.FormValue("attendee_cap"), 10, 64); err == nil && v > 0 { + attendeeCap = v + } + if attendeeCap > maxAttendeeCap { + attendeeCap = maxAttendeeCap + } + } + slug := randomSlug() token := randomToken() event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{ Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description, + AttendeeCap: attendeeCap, }) if err != nil { log.Printf("create event: %v", err) @@ -154,6 +176,10 @@ type EventPageData struct { DescriptionHTML template.HTML User *db.User AuthEnabled bool + Capped bool + SpotsLeft int64 + CapFull bool + PlusOneMax int64 } func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { @@ -217,6 +243,22 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve descHTML = RenderMarkdown(event.Description) } + capped := s.features.Auth && event.AttendeeCap > 0 + var spotsLeft int64 + plusOneMax := int64(maxPlusOne) + if capped { + spotsLeft = event.AttendeeCap - totalGoing + if spotsLeft < 0 { + spotsLeft = 0 + } + if spotsLeft-1 < plusOneMax { + plusOneMax = spotsLeft - 1 + } + if plusOneMax < 0 { + plusOneMax = 0 + } + } + return &EventPageData{ Event: event, Slots: slotViews, @@ -228,6 +270,10 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve DescriptionHTML: descHTML, User: s.currentUser(r), AuthEnabled: s.features.Auth, + Capped: capped, + SpotsLeft: spotsLeft, + CapFull: capped && totalGoing >= event.AttendeeCap, + PlusOneMax: plusOneMax, }, nil } @@ -324,6 +370,38 @@ 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 + 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{ + 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 + } + } + } + // Optional slot claim if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" { slotID, err := strconv.ParseInt(slotIDStr, 10, 64) @@ -363,27 +441,33 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) { } // Create RSVP (deduped — skip if already on the list) - _, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{ - 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 !alreadyRsvped { + var userID sql.NullInt64 + if user != nil { + userID = sql.NullInt64{Int64: user.ID, Valid: true} } - 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, + _, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{ + EventID: event.ID, Name: name, }) - if err != nil { - log.Printf("create rsvp: %v", err) - http.Error(w, "Failed", http.StatusInternalServerError) - return + 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 err != nil { + log.Printf("create rsvp: %v", err) + http.Error(w, "Failed", http.StatusInternalServerError) + return + } } } @@ -442,6 +526,29 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) { plusOne = 10 } + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil { + 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 + } + 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 { + 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, }) diff --git a/handlers_test.go b/handlers_test.go index 19aa0c1..eea6dde 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -215,6 +215,91 @@ func TestUpdateRsvp(t *testing.T) { } } +func TestCountGoing(t *testing.T) { + _, q := setupTestDB(t) + ctx := context.Background() + event := createTestEvent(t, q) + + going, err := q.CountGoing(ctx, event.ID) + if err != nil { + t.Fatal(err) + } + if going != 0 { + t.Fatalf("expected 0 going on empty event, got %d", going) + } + + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 0}) + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2}) + + going, err = q.CountGoing(ctx, event.ID) + if err != nil { + t.Fatal(err) + } + if going != 4 { + t.Fatalf("expected 4 going (2 RSVPs + 2 plus-ones), got %d", going) + } +} + +func TestGetRsvpByUser(t *testing.T) { + _, q := setupTestDB(t) + ctx := context.Background() + event := createTestEvent(t, q) + + user, err := q.CreateUserByEmail(ctx, sql.NullString{String: "alice@example.com", Valid: true}) + if err != nil { + t.Fatal(err) + } + + _, err = q.GetRsvpByUser(ctx, db.GetRsvpByUserParams{ + EventID: event.ID, UserID: sql.NullInt64{Int64: user.ID, Valid: true}, + }) + if err != sql.ErrNoRows { + t.Fatalf("expected ErrNoRows before RSVP, got %v", err) + } + + q.CreateRsvp(ctx, db.CreateRsvpParams{ + EventID: event.ID, Name: "Alice", PlusOne: 1, + UserID: sql.NullInt64{Int64: user.ID, Valid: true}, + }) + + rsvp, err := q.GetRsvpByUser(ctx, db.GetRsvpByUserParams{ + EventID: event.ID, UserID: sql.NullInt64{Int64: user.ID, Valid: true}, + }) + if err != nil { + t.Fatal(err) + } + if rsvp.Name != "Alice" { + t.Fatalf("expected RSVP name Alice, got %s", rsvp.Name) + } +} + +func TestAttendeeCapArithmetic(t *testing.T) { + _, q := setupTestDB(t) + ctx := context.Background() + event, err := q.CreateEvent(ctx, db.CreateEventParams{ + Slug: "capped", Title: "Capped Event", Date: "June 1", Time: "2pm", + Location: "Park", AdminToken: "tok456", AttendeeCap: 3, + }) + if err != nil { + t.Fatal(err) + } + if event.AttendeeCap != 3 { + t.Fatalf("expected AttendeeCap=3, got %d", event.AttendeeCap) + } + + q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1}) + + going, _ := q.CountGoing(ctx, event.ID) + // Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap. + if going+1+1 <= event.AttendeeCap { + t.Fatalf("expected RSVP +1 to exceed cap (going=%d, cap=%d)", going, event.AttendeeCap) + } + // A solo RSVP still fits. + if going+1+0 > event.AttendeeCap { + t.Fatalf("expected solo RSVP to fit (going=%d, cap=%d)", going, event.AttendeeCap) + } +} + func TestRsvpPlusOne(t *testing.T) { _, q := setupTestDB(t) ctx := context.Background() diff --git a/migrate.go b/migrate.go index 89bec4b..10e039f 100644 --- a/migrate.go +++ b/migrate.go @@ -33,6 +33,9 @@ func runMigrations(database *sql.DB) { user_agent TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, + // 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)`, } for _, m := range migrations { _, err := database.Exec(m) diff --git a/schema.sql b/schema.sql index 94894cc..0620de2 100644 --- a/schema.sql +++ b/schema.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS events ( location TEXT NOT NULL, admin_token TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', + attendee_cap INTEGER NOT NULL DEFAULT 0, -- 0 = uncapped; capped events require login to RSVP user_id INTEGER REFERENCES users(id), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -34,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, + user_id INTEGER REFERENCES users(id), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/templates/event.html b/templates/event.html index 004e649..7f1b1cc 100644 --- a/templates/event.html +++ b/templates/event.html @@ -19,7 +19,7 @@ {{define "content"}}
-
Open · {{.TotalGoing}} going
+
Open · {{.TotalGoing}} going{{if .Capped}} · {{if .CapFull}}FULL{{else}}{{.SpotsLeft}} spot{{if ne .SpotsLeft 1}}s{{end}} left{{end}}{{end}}

{{.Event.Title}}

{{if .Event.Date}}📅 {{.Event.Date}}{{end}} @@ -41,6 +41,23 @@ +{{if and .Capped .CapFull}} +
+
Event is full
+

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

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

+ This event has limited spots ({{.SpotsLeft}} left), so RSVPs require an account. +

+ Log in to RSVP ↗ +
+{{else}}
I'm coming →
- +
@@ -73,6 +90,7 @@
+{{end}} {{if .IsAdmin}} diff --git a/templates/home.html b/templates/home.html index d3e723e..2e34c8c 100644 --- a/templates/home.html +++ b/templates/home.html @@ -33,6 +33,12 @@
+ {{if .AuthEnabled}} +
+ + +
+ {{end}} diff --git a/templates/login.html b/templates/login.html index 1debbbb..9af3a3c 100644 --- a/templates/login.html +++ b/templates/login.html @@ -14,6 +14,7 @@
{{if eq .Step "identify"}}
+ {{if .Next}}{{end}}
@@ -23,6 +24,7 @@ {{else}} + {{if .Next}}{{end}}

Code sent to {{.Identifier}}

diff --git a/templates/name.html b/templates/name.html index 4e2ca56..96d63ee 100644 --- a/templates/name.html +++ b/templates/name.html @@ -13,6 +13,7 @@
+ {{if .Next}}{{end}}
- +
{{if .GoingList}} {{range .GoingList}}