Compare commits

..

4 Commits

Author SHA1 Message Date
Ryan Chen 891b49b4c0 Let admins edit event date and time
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:21:01 -04:00
Ryan Chen 1f0effb902 Add privacy policy and terms of service pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:17:16 -04:00
Ryan Chen d005080aca 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>
2026-06-10 19:36:57 -04:00
Ryan Chen 2aa3abf035 Rebuild legacy users table to add email and drop phone NOT NULL
The migration ALTER TABLE users ADD COLUMN email TEXT UNIQUE always
failed ("Cannot add a UNIQUE column" — SQLite doesn't support it), so
phone-era DBs never got an email column and verify 500'd with "no such
column: email". Even with the column added, phone NOT NULL would reject
email-only users. SQLite can't ALTER either change, so detect the legacy
shape (missing email column) and rebuild the table, preserving user ids
and sessions (FKs off so DROP TABLE doesn't cascade).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:13:37 -04:00
17 changed files with 588 additions and 48 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
}
+13 -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
@@ -18,6 +18,9 @@ UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?;
-- name: UpdateEventLocation :exec
UPDATE events SET location = ? WHERE id = ?;
-- name: UpdateEventDateTime :exec
UPDATE events SET date = ?, time = ? WHERE id = ?;
-- name: DeleteEvent :exec
DELETE FROM events WHERE id = ?;
@@ -65,8 +68,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 +81,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
+74 -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
@@ -720,6 +767,21 @@ func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error
return err
}
const updateEventDateTime = `-- name: UpdateEventDateTime :exec
UPDATE events SET date = ?, time = ? WHERE id = ?
`
type UpdateEventDateTimeParams struct {
Date string
Time string
ID int64
}
func (q *Queries) UpdateEventDateTime(ctx context.Context, arg UpdateEventDateTimeParams) error {
_, err := q.db.ExecContext(ctx, updateEventDateTime, arg.Date, arg.Time, arg.ID)
return err
}
const updateEventDescription = `-- name: UpdateEventDescription :exec
UPDATE events SET description = ? WHERE id = ?
`
+154 -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,
})
@@ -600,6 +707,26 @@ func (s *Server) handleUpdateLocation(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther)
}
func (s *Server) handleUpdateDateTime(w http.ResponseWriter, r *http.Request) {
event := s.authorizeAdmin(w, r, false)
if event == nil {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
r.ParseForm()
date := sanitize(r.FormValue("date"), maxFieldLen)
time_ := sanitize(r.FormValue("time"), maxFieldLen)
s.q.UpdateEventDateTime(r.Context(), db.UpdateEventDateTimeParams{
Date: date,
Time: time_,
ID: event.ID,
})
http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther)
}
func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) {
event := s.authorizeAdmin(w, r, false)
if event == nil {
@@ -669,3 +796,11 @@ func (s *Server) handleDeleteSlot(w http.ResponseWriter, r *http.Request) {
}
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
}
func (s *Server) handlePrivacy(w http.ResponseWriter, r *http.Request) {
pageTmpl["privacy"].ExecuteTemplate(w, "layout", nil)
}
func (s *Server) handleTerms(w http.ResponseWriter, r *http.Request) {
pageTmpl["terms"].ExecuteTemplate(w, "layout", nil)
}
+109
View File
@@ -215,6 +215,115 @@ func TestUpdateRsvp(t *testing.T) {
}
}
func TestUpdateEventDateTime(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
err := q.UpdateEventDateTime(ctx, db.UpdateEventDateTimeParams{
Date: "Sunday, July 20", Time: "5:30 PM", ID: event.ID,
})
if err != nil {
t.Fatal(err)
}
updated, err := q.GetEventBySlug(ctx, event.Slug)
if err != nil {
t.Fatal(err)
}
if updated.Date != "Sunday, July 20" {
t.Fatalf("expected date 'Sunday, July 20', got %s", updated.Date)
}
if updated.Time != "5:30 PM" {
t.Fatalf("expected time '5:30 PM', got %s", updated.Time)
}
}
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()
+6 -1
View File
@@ -124,7 +124,7 @@ func main() {
// Parse each page with layout + shared partials
pageTmpl = make(map[string]*template.Template)
shared := []string{"templates/layout.html", "templates/slots.html"}
for _, page := range []string{"home", "event", "login", "dashboard", "name"} {
for _, page := range []string{"home", "event", "login", "dashboard", "name", "privacy", "terms"} {
files := append([]string{"templates/" + page + ".html"}, shared...)
pageTmpl[page] = template.Must(
template.New("").Funcs(funcMap).ParseFS(templateFS, files...),
@@ -172,6 +172,10 @@ func main() {
})
}
// Legal
r.Get("/privacy", srv.handlePrivacy)
r.Get("/terms", srv.handleTerms)
// Home / create event
r.Get("/", srv.handleHome)
if features.Auth {
@@ -199,6 +203,7 @@ func main() {
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription)
r.Post("/e/{slug}/admin/{token}/location", srv.handleUpdateLocation)
r.Post("/e/{slug}/admin/{token}/datetime", srv.handleUpdateDateTime)
r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
+55 -3
View File
@@ -10,9 +10,6 @@ func runMigrations(database *sql.DB) {
migrations := []string{
`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`,
`ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`,
// Users may have email, phone, or both. Add whichever column is missing.
`ALTER TABLE users ADD COLUMN phone TEXT UNIQUE`,
`ALTER TABLE users ADD COLUMN email TEXT UNIQUE`,
// Verification codes use a generic identifier column.
`ALTER TABLE verification_codes ADD COLUMN identifier TEXT NOT NULL DEFAULT ''`,
// Indexes for auth tables (created here so they run after column migrations).
@@ -36,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)
@@ -47,4 +47,56 @@ func runMigrations(database *sql.DB) {
log.Printf("migration warning: %v", err)
}
}
rebuildLegacyUsersTable(database)
}
// rebuildLegacyUsersTable migrates users tables from the phone-auth era
// (phone NOT NULL, no email column). SQLite can't add a UNIQUE column or
// drop NOT NULL via ALTER TABLE, so the table has to be rebuilt. Detected
// by the missing email column.
func rebuildLegacyUsersTable(database *sql.DB) {
var hasEmail int
err := database.QueryRow(
`SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'email'`,
).Scan(&hasEmail)
if err != nil || hasEmail > 0 {
return
}
// Disable FK enforcement so DROP TABLE users doesn't cascade-delete
// sessions. main.go sets MaxOpenConns(1), so these pragmas apply to
// the same connection the transaction runs on.
database.Exec(`PRAGMA foreign_keys=OFF`)
defer database.Exec(`PRAGMA foreign_keys=ON`)
tx, err := database.Begin()
if err != nil {
log.Printf("users rebuild: %v", err)
return
}
defer tx.Rollback()
stmts := []string{
`CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT UNIQUE,
email TEXT UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`INSERT INTO users_new (id, phone, name, created_at)
SELECT id, phone, name, created_at FROM users`,
`DROP TABLE users`,
`ALTER TABLE users_new RENAME TO users`,
}
for _, stmt := range stmts {
if _, err := tx.Exec(stmt); err != nil {
log.Printf("users rebuild: %v", err)
return
}
}
if err := tx.Commit(); err != nil {
log.Printf("users rebuild: %v", err)
}
}
+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
);
+35 -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,8 +90,24 @@
<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: Date &amp; time</div>
<div class="claim-form-wrapper">
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/datetime">
<div class="form-row">
<label>Date</label>
<input type="text" name="date" value="{{.Event.Date}}" placeholder="e.g. Saturday, June 14">
</div>
<div class="form-row">
<label>Time</label>
<input type="text" name="time" value="{{.Event.Time}}" placeholder="e.g. 2:00 PM">
</div>
<button class="btn-submit" type="submit">Save date &amp; time</button>
</form>
</div>
<div class="section-label" style="margin-top:40px">Admin: Location</div>
<div class="claim-form-wrapper">
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/location">
+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>
+5
View File
@@ -477,5 +477,10 @@ document.body.addEventListener('closeModal', function() {
});
</script>
<div id="edit-modal"></div>
<footer style="border-top:2.5px solid var(--border);padding:16px 24px;text-align:center;font-size:0.8rem;color:#777;margin-top:auto;">
<a href="/privacy" style="color:#777;text-decoration:none;margin:0 8px;">Privacy</a>
<span style="color:#bbb;">·</span>
<a href="/terms" style="color:#777;text-decoration:none;margin:0 8px;">Terms</a>
</footer>
</body>
</html>{{end}}
+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
+39
View File
@@ -0,0 +1,39 @@
{{define "title"}}Privacy Policy — bbq{{end}}
{{define "content"}}
<div class="page-wrap" style="max-width:680px;margin:48px auto;padding:0 24px 64px;">
<h1 style="font-size:2rem;font-weight:800;letter-spacing:-0.03em;margin-bottom:8px;">Privacy Policy</h1>
<p style="color:#555;font-size:0.9rem;margin-bottom:40px;">Last updated July 3, 2026</p>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">What we collect</h2>
<p style="line-height:1.7;margin-bottom:12px;">When you RSVP or claim a slot at an event, we store your <strong>name</strong> and an optional <strong>note</strong> you provide. These are visible to anyone with the event link and to the event organizer.</p>
<p style="line-height:1.7;margin-bottom:12px;">If you create an event with an attendee cap, you may be asked to sign in with your <strong>email address</strong>. We store your email and a short-lived verification code to confirm it. We do not store passwords.</p>
<p style="line-height:1.7;">We do not collect addresses, phone numbers (unless you opt into SMS notifications on a specific event), or payment information.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">How we use it</h2>
<p style="line-height:1.7;">Your data is used only to run the event signup — showing who has claimed what, letting you manage your own RSVP, and letting the organizer see their guest list. We do not sell, share, or use your data for advertising.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Third parties</h2>
<p style="line-height:1.7;">We load fonts from Google Fonts and the HTMX library from unpkg. These requests go to their servers and are subject to their privacy policies. We have no other third-party integrations and use no analytics or tracking tools.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Cookies &amp; sessions</h2>
<p style="line-height:1.7;">If you sign in, a session token is stored in a cookie to keep you logged in. No other cookies are set.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Data retention</h2>
<p style="line-height:1.7;">Event and RSVP data persists indefinitely unless an organizer deletes slots or the event is removed. There is no automated purge schedule. If you want your data removed, contact us.</p>
</section>
<section>
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Contact</h2>
<p style="line-height:1.7;">Questions or deletion requests: <a href="mailto:ryan@torrtle.co" style="color:var(--ink);">ryan@torrtle.co</a></p>
</section>
</div>
{{end}}
+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}}
+33
View File
@@ -0,0 +1,33 @@
{{define "title"}}Terms of Service — bbq{{end}}
{{define "content"}}
<div class="page-wrap" style="max-width:680px;margin:48px auto;padding:0 24px 64px;">
<h1 style="font-size:2rem;font-weight:800;letter-spacing:-0.03em;margin-bottom:8px;">Terms of Service</h1>
<p style="color:#555;font-size:0.9rem;margin-bottom:40px;">Last updated July 3, 2026</p>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">What this is</h2>
<p style="line-height:1.7;">bbq is a simple potluck signup tool. You can create an event, share a link, and let people claim slots. That's it.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Your responsibilities</h2>
<p style="line-height:1.7;margin-bottom:12px;">By using this service you agree not to use it for spam, harassment, illegal activity, or anything that would embarrass a reasonable person at a potluck.</p>
<p style="line-height:1.7;">Event organizers are responsible for the events they create and the information they share with guests. Guests are responsible for the names and notes they submit.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">No warranty</h2>
<p style="line-height:1.7;">This service is provided as-is, without any warranty. We make no guarantees about uptime, data durability, or fitness for any particular purpose. Use it at your own risk.</p>
</section>
<section style="margin-bottom:32px;">
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Changes</h2>
<p style="line-height:1.7;">We may update these terms at any time. Continued use of the service after changes constitutes acceptance of the new terms.</p>
</section>
<section>
<h2 style="font-size:1.1rem;font-weight:700;margin-bottom:12px;">Contact</h2>
<p style="line-height:1.7;"><a href="mailto:ryan@torrtle.co" style="color:var(--ink);">ryan@torrtle.co</a></p>
</section>
</div>
{{end}}