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"
"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)
}
+2
View File
@@ -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
}
+10 -4
View File
@@ -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
+59 -12
View File
@@ -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
+126 -19
View File
@@ -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,
})
+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) {
_, q := setupTestDB(t)
ctx := context.Background()
+3
View File
@@ -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)
+2
View File
@@ -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
);
+20 -2
View File
@@ -19,7 +19,7 @@
{{define "content"}}
<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>
<div class="event-meta">
{{if .Event.Date}}<span>&#128197; {{.Event.Date}}</span>{{end}}
@@ -41,6 +41,23 @@
<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="form-title">I'm coming &#8594;</div>
<form hx-post="/e/{{.Event.Slug}}/rsvp"
@@ -64,7 +81,7 @@
{{end}}
<div class="form-row">
<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 class="form-row" id="claim-note">
<label>Note (optional)</label>
@@ -73,6 +90,7 @@
<button class="btn-submit" type="submit">Count me in &#8599;</button>
</form>
</div>
{{end}}
{{if .IsAdmin}}
<div class="section-label" style="margin-top:40px">Admin: Location</div>
+6
View File
@@ -33,6 +33,12 @@
<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>
</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>
+2
View File
@@ -14,6 +14,7 @@
<div class="claim-form-wrapper">
{{if eq .Step "identify"}}
<form method="POST" action="/login">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="form-row">
<label>Email</label>
<input type="email" name="identifier" placeholder="you@example.com" required autofocus>
@@ -23,6 +24,7 @@
{{else}}
<form method="POST" action="/login/verify">
<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;">
Code sent to <strong>{{.Identifier}}</strong>
</p>
+1
View File
@@ -13,6 +13,7 @@
<div class="claim-form-wrapper">
<form method="POST" action="/account/name">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="form-row">
<label>Name</label>
<input type="text" name="name" placeholder="Your name" required autofocus
+1 -1
View File
@@ -32,7 +32,7 @@
{{end}}
</div>
<div class="section-label">Going ({{.TotalGoing}})</div>
<div class="section-label">Going ({{.TotalGoing}}{{if .Capped}} / {{.Event.AttendeeCap}}{{end}})</div>
<div class="rsvp-list">
{{if .GoingList}}
{{range .GoingList}}