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
+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