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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+126
-19
@@ -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,27 +441,33 @@ 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)
|
||||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
if !alreadyRsvped {
|
||||||
EventID: event.ID, Name: name,
|
var userID sql.NullInt64
|
||||||
})
|
if user != nil {
|
||||||
if err == sql.ErrNoRows {
|
userID = sql.NullInt64{Int64: user.ID, Valid: true}
|
||||||
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if count >= maxRsvps {
|
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||||
http.Error(w, "RSVP list is full", http.StatusConflict)
|
EventID: event.ID, Name: name,
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
|
||||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == sql.ErrNoRows {
|
||||||
log.Printf("create rsvp: %v", err)
|
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
||||||
http.Error(w, "Failed", http.StatusInternalServerError)
|
if err != nil {
|
||||||
return
|
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
|
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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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>📅 {{.Event.Date}}</span>{{end}}
|
{{if .Event.Date}}<span>📅 {{.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 ↗</a>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<div class="form-title">I'm coming →</div>
|
<div class="form-title">I'm coming →</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 ↗</button>
|
<button class="btn-submit" type="submit">Count me in ↗</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user