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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:36:57 -04:00
parent 2aa3abf035
commit d005080aca
13 changed files with 368 additions and 44 deletions
+51 -6
View File
@@ -11,6 +11,7 @@ import (
"log" "log"
"math/big" "math/big"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
@@ -63,39 +64,62 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
func (s *Server) requireAuth(next http.Handler) http.Handler { func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.currentUser(r) == nil { 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 return
} }
next.ServeHTTP(w, r) 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. // isEmail returns true if the input looks like an email address.
func isEmail(s string) bool { func isEmail(s string) bool {
return strings.Contains(s, "@") return strings.Contains(s, "@")
} }
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.URL.Query().Get("next"))
if s.currentUser(r) != nil { 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 return
} }
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "identify", "Step": "identify",
"Next": next,
"AuthEnabled": true, "AuthEnabled": true,
}) })
} }
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { 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")) raw := strings.TrimSpace(r.FormValue("identifier"))
if raw == "" { if raw == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, loginURL, http.StatusSeeOther)
return return
} }
identifier := strings.ToLower(raw) identifier := strings.ToLower(raw)
if !isEmail(identifier) { if !isEmail(identifier) {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, loginURL, http.StatusSeeOther)
return return
} }
@@ -118,11 +142,13 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "code", "Step": "code",
"Identifier": identifier, "Identifier": identifier,
"Next": next,
"AuthEnabled": true, "AuthEnabled": true,
}) })
} }
func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) { func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
identifier := strings.TrimSpace(r.FormValue("identifier")) identifier := strings.TrimSpace(r.FormValue("identifier"))
code := strings.TrimSpace(r.FormValue("code")) 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{ pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "code", "Step": "code",
"Identifier": identifier, "Identifier": identifier,
"Next": next,
"Error": "Invalid or expired code. Try again.", "Error": "Invalid or expired code. Try again.",
"AuthEnabled": true, "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 has no name, send them to set it
if user.Name == "" { 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 return
} }
if next != "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) 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) user := s.currentUser(r)
pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{ pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{
"User": user, "User": user,
"Next": safeNext(r.URL.Query().Get("next")),
"AuthEnabled": true, "AuthEnabled": true,
}) })
} }
func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) {
user := s.currentUser(r) user := s.currentUser(r)
next := safeNext(r.FormValue("next"))
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
if 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 return
} }
s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{ s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{
Name: name, Name: name,
ID: user.ID, ID: user.ID,
}) })
if next != "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
} }
+2
View File
@@ -26,6 +26,7 @@ type Event struct {
Location string Location string
AdminToken string AdminToken string
Description string Description string
AttendeeCap int64
UserID sql.NullInt64 UserID sql.NullInt64
CreatedAt time.Time CreatedAt time.Time
} }
@@ -36,6 +37,7 @@ type Rsvp struct {
Name string Name string
Note string Note string
PlusOne int64 PlusOne int64
UserID sql.NullInt64
CreatedAt time.Time CreatedAt time.Time
} }
+10 -4
View File
@@ -5,8 +5,8 @@ SELECT * FROM events WHERE slug = ?;
SELECT * FROM events WHERE admin_token = ?; SELECT * FROM events WHERE admin_token = ?;
-- name: CreateEvent :one -- name: CreateEvent :one
INSERT INTO events (slug, title, date, time, location, admin_token, description) INSERT INTO events (slug, title, date, time, location, admin_token, description, attendee_cap)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *; RETURNING *;
-- name: UpdateEventDescription :exec -- name: UpdateEventDescription :exec
@@ -65,8 +65,8 @@ SELECT COUNT(*) FROM claims WHERE slot_id = ?;
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) INSERT INTO rsvps (event_id, name, note, plus_one, user_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
RETURNING *; RETURNING *;
-- name: DeleteRsvp :exec -- name: DeleteRsvp :exec
@@ -78,6 +78,12 @@ SELECT COUNT(*) FROM rsvps WHERE event_id = ?;
-- name: GetRsvpByName :one -- name: GetRsvpByName :one
SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1; 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 -- 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
+59 -12
View File
@@ -22,6 +22,17 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e
return count, err 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 const countRsvps = `-- name: CountRsvps :one
SELECT COUNT(*) FROM rsvps WHERE event_id = ? 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 const createEvent = `-- name: CreateEvent :one
INSERT INTO events (slug, title, date, time, location, admin_token, description) INSERT INTO events (slug, title, date, time, location, admin_token, description, attendee_cap)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, slug, title, date, time, location, admin_token, description, user_id, created_at RETURNING id, slug, title, date, time, location, admin_token, description, attendee_cap, user_id, created_at
` `
type CreateEventParams struct { type CreateEventParams struct {
@@ -72,6 +83,7 @@ type CreateEventParams struct {
Location string Location string
AdminToken string AdminToken string
Description string Description string
AttendeeCap int64
} }
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { 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.Location,
arg.AdminToken, arg.AdminToken,
arg.Description, arg.Description,
arg.AttendeeCap,
) )
var i Event var i Event
err := row.Scan( err := row.Scan(
@@ -94,6 +107,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
&i.Location, &i.Location,
&i.AdminToken, &i.AdminToken,
&i.Description, &i.Description,
&i.AttendeeCap,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -101,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) INSERT INTO rsvps (event_id, name, note, plus_one, user_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
RETURNING id, event_id, name, note, plus_one, created_at RETURNING id, event_id, name, note, plus_one, user_id, created_at
` `
type CreateRsvpParams struct { type CreateRsvpParams struct {
@@ -111,6 +125,7 @@ type CreateRsvpParams struct {
Name string Name string
Note string Note string
PlusOne int64 PlusOne int64
UserID sql.NullInt64
} }
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) { 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.Name,
arg.Note, arg.Note,
arg.PlusOne, arg.PlusOne,
arg.UserID,
) )
var i Rsvp var i Rsvp
err := row.Scan( err := row.Scan(
@@ -127,6 +143,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.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -328,7 +345,7 @@ func (q *Queries) GetClaimByID(ctx context.Context, id int64) (Claim, error) {
} }
const getEventByAdminToken = `-- name: GetEventByAdminToken :one 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) { 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.Location,
&i.AdminToken, &i.AdminToken,
&i.Description, &i.Description,
&i.AttendeeCap,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -350,7 +368,7 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (
} }
const getEventBySlug = `-- name: GetEventBySlug :one 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) { 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.Location,
&i.AdminToken, &i.AdminToken,
&i.Description, &i.Description,
&i.AttendeeCap,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -372,7 +391,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, 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) { 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.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.UserID,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
} }
const getRsvpByName = `-- name: GetRsvpByName :one 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 { type GetRsvpByNameParams struct {
@@ -407,6 +427,31 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
&i.Name, &i.Name,
&i.Note, &i.Note,
&i.PlusOne, &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, &i.CreatedAt,
) )
return i, err return i, err
@@ -569,7 +614,7 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim,
} }
const listEventsByUser = `-- name: ListEventsByUser :many 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) { 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.Location,
&i.AdminToken, &i.AdminToken,
&i.Description, &i.Description,
&i.AttendeeCap,
&i.UserID, &i.UserID,
&i.CreatedAt, &i.CreatedAt,
); err != nil { ); err != nil {
@@ -607,7 +653,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, 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) { 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.Name,
&i.Note, &i.Note,
&i.PlusOne, &i.PlusOne,
&i.UserID,
&i.CreatedAt, &i.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
+108 -1
View File
@@ -24,8 +24,18 @@ const (
maxRsvps = 200 maxRsvps = 200
maxClaims = 50 maxClaims = 50
maxMaxClaims = 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 { func sanitize(s string, maxLen int) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if len(s) > maxLen { if len(s) > maxLen {
@@ -73,10 +83,22 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
return 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() slug := randomSlug()
token := randomToken() token := randomToken()
event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{ event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{
Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description, Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description,
AttendeeCap: attendeeCap,
}) })
if err != nil { if err != nil {
log.Printf("create event: %v", err) log.Printf("create event: %v", err)
@@ -154,6 +176,10 @@ type EventPageData struct {
DescriptionHTML template.HTML DescriptionHTML template.HTML
User *db.User User *db.User
AuthEnabled bool AuthEnabled bool
Capped bool
SpotsLeft int64
CapFull bool
PlusOneMax int64
} }
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { 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) 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{ return &EventPageData{
Event: event, Event: event,
Slots: slotViews, Slots: slotViews,
@@ -228,6 +270,10 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
DescriptionHTML: descHTML, DescriptionHTML: descHTML,
User: s.currentUser(r), User: s.currentUser(r),
AuthEnabled: s.features.Auth, AuthEnabled: s.features.Auth,
Capped: capped,
SpotsLeft: spotsLeft,
CapFull: capped && totalGoing >= event.AttendeeCap,
PlusOneMax: plusOneMax,
}, nil }, nil
} }
@@ -324,6 +370,38 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
return 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 // Optional slot claim
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" { if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
slotID, err := strconv.ParseInt(slotIDStr, 10, 64) slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
@@ -363,6 +441,11 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
} }
// Create RSVP (deduped — skip if already on the list) // 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{ _, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
EventID: event.ID, Name: name, EventID: event.ID, Name: name,
}) })
@@ -378,7 +461,7 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
} }
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{ _, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, UserID: userID,
}) })
if err != nil { if err != nil {
log.Printf("create rsvp: %v", err) log.Printf("create rsvp: %v", err)
@@ -386,6 +469,7 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
}
s.notify(slug) s.notify(slug)
@@ -442,6 +526,29 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
plusOne = 10 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{ err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID, Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
}) })
+85
View File
@@ -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) { func TestRsvpPlusOne(t *testing.T) {
_, q := setupTestDB(t) _, q := setupTestDB(t)
ctx := context.Background() ctx := context.Background()
+3
View File
@@ -33,6 +33,9 @@ func runMigrations(database *sql.DB) {
user_agent TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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 { for _, m := range migrations {
_, err := database.Exec(m) _, err := database.Exec(m)
+2
View File
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS events (
location TEXT NOT NULL, location TEXT NOT NULL,
admin_token TEXT NOT NULL UNIQUE, admin_token TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '', 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), user_id INTEGER REFERENCES users(id),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -34,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,
user_id INTEGER REFERENCES users(id),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
+20 -2
View File
@@ -19,7 +19,7 @@
{{define "content"}} {{define "content"}}
<div class="event-header"> <div class="event-header">
<div class="event-tag">Open · {{.TotalGoing}} going</div> <div class="event-tag">Open · {{.TotalGoing}} going{{if .Capped}} · {{if .CapFull}}FULL{{else}}{{.SpotsLeft}} spot{{if ne .SpotsLeft 1}}s{{end}} left{{end}}{{end}}</div>
<h1 class="event-title">{{.Event.Title}}</h1> <h1 class="event-title">{{.Event.Title}}</h1>
<div class="event-meta"> <div class="event-meta">
{{if .Event.Date}}<span>&#128197; {{.Event.Date}}</span>{{end}} {{if .Event.Date}}<span>&#128197; {{.Event.Date}}</span>{{end}}
@@ -41,6 +41,23 @@
<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}}
<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="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.
</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 &#8599;</a>
</div>
{{else}}
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
<div class="form-title">I'm coming &#8594;</div> <div class="form-title">I'm coming &#8594;</div>
<form hx-post="/e/{{.Event.Slug}}/rsvp" <form hx-post="/e/{{.Event.Slug}}/rsvp"
@@ -64,7 +81,7 @@
{{end}} {{end}}
<div class="form-row"> <div class="form-row">
<label>Bringing anyone?</label> <label>Bringing anyone?</label>
<input type="number" name="plus_one" value="0" min="0" max="10"> <input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}">
</div> </div>
<div class="form-row" id="claim-note"> <div class="form-row" id="claim-note">
<label>Note (optional)</label> <label>Note (optional)</label>
@@ -73,6 +90,7 @@
<button class="btn-submit" type="submit">Count me in &#8599;</button> <button class="btn-submit" type="submit">Count me in &#8599;</button>
</form> </form>
</div> </div>
{{end}}
{{if .IsAdmin}} {{if .IsAdmin}}
<div class="section-label" style="margin-top:40px">Admin: Location</div> <div class="section-label" style="margin-top:40px">Admin: Location</div>
+6
View File
@@ -33,6 +33,12 @@
<label>Description (optional, markdown supported)</label> <label>Description (optional, markdown supported)</label>
<textarea name="description" rows="4" placeholder="e.g. Bring your own blanket! We'll have a grill set up near the big oak tree." style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;"></textarea> <textarea name="description" rows="4" placeholder="e.g. Bring your own blanket! We'll have a grill set up near the big oak tree." style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;"></textarea>
</div> </div>
{{if .AuthEnabled}}
<div class="form-row">
<label>Attendee cap (optional — guests must log in to RSVP)</label>
<input type="number" name="attendee_cap" min="0" max="1000" placeholder="e.g. 30 — leave blank for unlimited">
</div>
{{end}}
<div class="section-label" style="margin:24px 0 16px">Slots</div> <div class="section-label" style="margin:24px 0 16px">Slots</div>
+2
View File
@@ -14,6 +14,7 @@
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
{{if eq .Step "identify"}} {{if eq .Step "identify"}}
<form method="POST" action="/login"> <form method="POST" action="/login">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="form-row"> <div class="form-row">
<label>Email</label> <label>Email</label>
<input type="email" name="identifier" placeholder="you@example.com" required autofocus> <input type="email" name="identifier" placeholder="you@example.com" required autofocus>
@@ -23,6 +24,7 @@
{{else}} {{else}}
<form method="POST" action="/login/verify"> <form method="POST" action="/login/verify">
<input type="hidden" name="identifier" value="{{.Identifier}}"> <input type="hidden" name="identifier" value="{{.Identifier}}">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;"> <p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;">
Code sent to <strong>{{.Identifier}}</strong> Code sent to <strong>{{.Identifier}}</strong>
</p> </p>
+1
View File
@@ -13,6 +13,7 @@
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
<form method="POST" action="/account/name"> <form method="POST" action="/account/name">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="form-row"> <div class="form-row">
<label>Name</label> <label>Name</label>
<input type="text" name="name" placeholder="Your name" required autofocus <input type="text" name="name" placeholder="Your name" required autofocus
+1 -1
View File
@@ -32,7 +32,7 @@
{{end}} {{end}}
</div> </div>
<div class="section-label">Going ({{.TotalGoing}})</div> <div class="section-label">Going ({{.TotalGoing}}{{if .Capped}} / {{.Event.AttendeeCap}}{{end}})</div>
<div class="rsvp-list"> <div class="rsvp-list">
{{if .GoingList}} {{if .GoingList}}
{{range .GoingList}} {{range .GoingList}}