From 471cc3ad8c37142e5c4a77890449ed7ffce022b9 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 18 May 2026 08:45:15 -0400 Subject: [PATCH] Switch to phone auth via Twilio SMS and add feature flag system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace email-based auth (Resend) with phone-based auth (Twilio SMS). Add BBQ_FEATURES env var for toggling features at deploy time — when auth is disabled, no login routes are registered and the app works as before. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 73 ++++++++++ auth.go | 280 +++++++++++++++++++++++++++++++++++++++ db/models.go | 23 ++++ db/queries.sql | 38 ++++++ db/queries.sql.go | 201 +++++++++++++++++++++++++++- features.go | 18 +++ handlers.go | 17 ++- main.go | 38 ++++-- migrate.go | 19 ++- schema.sql | 24 ++++ templates/dashboard.html | 37 ++++++ templates/event.html | 4 +- templates/layout.html | 3 + templates/login.html | 42 ++++++ templates/name.html | 24 ++++ 15 files changed, 820 insertions(+), 21 deletions(-) create mode 100644 CLAUDE.md create mode 100644 auth.go create mode 100644 features.go create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/name.html diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a53ed2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +Potluck signup app. No accounts — share a link, people claim slots. Open like a shared Google Sheet. + +## Commands + +```bash +# Build and run +go build -o bbq . && ./bbq + +# Docker +docker compose up -d --build + +# Regenerate DB layer after editing schema.sql or db/queries.sql +$(go env GOPATH)/bin/sqlc generate +``` + +## Environment Variables + +- `PORT` — Go server listen port (default `8080`) +- `BBQ_DB` — SQLite database path (default `bbq.db`) +- `BBQ_BASE_URL` — Absolute URL for OG images (e.g. `https://bbq.torrtle.co`) +- `HOST_PORT` — Compose host-side port mapping (default `8090`) + +## Architecture + +**Stack:** Go, chi router, sqlc + SQLite (mattn/go-sqlite3), html/template, HTMX + SSE + +**Design:** Soft Brutalism — cream `#f5f0e8`, black borders/shadows, yellow `#f5e642` accents, Bricolage Grotesque + DM Mono fonts + +### Data Flow + +`schema.sql` defines tables (events, slots, claims, rsvps) → `db/queries.sql` has SQL queries → `sqlc generate` produces `db/` package (models.go, queries.sql.go, db.go). Never edit generated files in `db/` directly. + +### Server (`main.go`) + +`Server` struct holds `*db.Queries`, `*sql.DB`, `baseURL`, and SSE client channels. SSE uses `subscribe/unsubscribe/notify` pattern — any mutation calls `notify(slug)` to push updates to all viewers. + +### Templates + +Go template inheritance doesn't work with a single `ParseFS`, so each page is parsed separately into `pageTmpl` map: +- `pageTmpl["home"]` / `pageTmpl["event"]` — full pages, rendered via `.ExecuteTemplate(w, "layout", data)` +- `pageTmpl["slots"]` — partial for HTMX responses, rendered via `.ExecuteTemplate(w, "slots-inner", data)` + +Template files: `layout.html` (shell + all CSS/JS), `slots.html` (shared partial), `event.html`, `home.html`. All CSS is inline in `layout.html`. + +### Handlers (`handlers.go`) + +`loadEventPage()` is the shared data loader — assembles `EventPageData` with event, slots+claims, rsvps, and counts. Used by both full page renders and HTMX partial responses. + +### OG Image (`ogimage.go`) + +Dynamic PNG generation at `/e/{slug}/og.png` using Go's `image` package with a custom 5x7 bitmap font. Renders event title, date/time/location in the app's visual style. + +### Routes + +- `GET /` — create event form +- `POST /events` — creates event + slots, redirects to admin URL +- `GET /e/{slug}` — guest view +- `POST /e/{slug}/claim` / `DELETE /e/{slug}/claim/{claimID}` — HTMX claim/unclaim +- `POST /e/{slug}/rsvp` / `DELETE /e/{slug}/rsvp/{rsvpID}` — HTMX RSVP +- `GET /e/{slug}/sse` — SSE stream for live updates +- `GET /e/{slug}/og.png` — dynamic OG image +- `GET /e/{slug}/admin/{token}` — admin view (same template, `IsAdmin=true`) +- `POST /e/{slug}/admin/{token}/slot` / `DELETE /e/{slug}/admin/{token}/slot/{slotID}` — manage slots + +### Auth Model + +No user auth. Admin access is via secret `admin_token` in the URL. Claims/RSVPs are open to anyone with the event link. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..4285310 --- /dev/null +++ b/auth.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/ryanchen/bbq/db" +) + +type contextKey string + +const userContextKey contextKey = "user" + +func (s *Server) currentUser(r *http.Request) *db.User { + if !s.features.Auth { + return nil + } + u, _ := r.Context().Value(userContextKey).(*db.User) + return u +} + +// sessionMiddleware loads the user from the session cookie into context. +func (s *Server) sessionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.features.Auth { + next.ServeHTTP(w, r) + return + } + cookie, err := r.Cookie("session") + if err != nil { + next.ServeHTTP(w, r) + return + } + sess, err := s.q.GetSession(r.Context(), cookie.Value) + if err != nil { + next.ServeHTTP(w, r) + return + } + var u db.User + row := s.db.QueryRowContext(r.Context(), + "SELECT id, phone, name, created_at FROM users WHERE id = ?", sess.UserID) + if err := row.Scan(&u.ID, &u.Phone, &u.Name, &u.CreatedAt); err != nil { + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), userContextKey, &u) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// requireAuth redirects to /login if not logged in. +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) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + if s.currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ + "Step": "phone", + "AuthEnabled": true, + }) +} + +func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { + phone := normalizePhone(r.FormValue("phone")) + if phone == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + code := generateCode() + expiresAt := time.Now().Add(10 * time.Minute) + s.q.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{ + Phone: phone, + Code: code, + ExpiresAt: expiresAt, + }) + + if err := sendVerificationSMS(phone, code); err != nil { + log.Printf("failed to send verification SMS to %s: %v", phone, err) + } + + pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ + "Step": "code", + "Phone": phone, + "AuthEnabled": true, + }) +} + +func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) { + phone := normalizePhone(r.FormValue("phone")) + code := strings.TrimSpace(r.FormValue("code")) + + vc, err := s.q.GetVerificationCode(r.Context(), db.GetVerificationCodeParams{ + Phone: phone, + Code: code, + }) + if err != nil { + pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ + "Step": "code", + "Phone": phone, + "Error": "Invalid or expired code. Try again.", + "AuthEnabled": true, + }) + return + } + + s.q.MarkVerificationCodeUsed(r.Context(), vc.ID) + + // Get or create user + user, err := s.q.GetUserByPhone(r.Context(), phone) + if err == sql.ErrNoRows { + user, err = s.q.CreateUser(r.Context(), phone) + } + if err != nil { + log.Printf("user lookup/create: %v", err) + http.Error(w, "Internal error", 500) + return + } + + // Create session + token := generateSessionToken() + expiresAt := time.Now().Add(30 * 24 * time.Hour) // 30 days + s.q.CreateSession(r.Context(), db.CreateSessionParams{ + Token: token, + UserID: user.ID, + ExpiresAt: expiresAt, + }) + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + Expires: expiresAt, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + // If user has no name, send them to set it + if user.Name == "" { + http.Redirect(w, r, "/account/name", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err == nil { + s.q.DeleteSession(r.Context(), cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + user := s.currentUser(r) + events, err := s.q.ListEventsByUser(r.Context(), sql.NullInt64{Int64: user.ID, Valid: true}) + if err != nil { + log.Printf("list events: %v", err) + http.Error(w, "Internal error", 500) + return + } + pageTmpl["dashboard"].ExecuteTemplate(w, "layout", map[string]any{ + "User": user, + "Events": events, + "AuthEnabled": true, + }) +} + +func (s *Server) handleNamePage(w http.ResponseWriter, r *http.Request) { + user := s.currentUser(r) + pageTmpl["name"].ExecuteTemplate(w, "layout", map[string]any{ + "User": user, + "AuthEnabled": true, + }) +} + +func (s *Server) handleNameSubmit(w http.ResponseWriter, r *http.Request) { + user := s.currentUser(r) + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Redirect(w, r, "/account/name", http.StatusSeeOther) + return + } + s.q.UpdateUserName(r.Context(), db.UpdateUserNameParams{ + Name: name, + ID: user.ID, + }) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +func generateCode() string { + n, _ := rand.Int(rand.Reader, big.NewInt(1000000)) + return fmt.Sprintf("%06d", n.Int64()) +} + +func generateSessionToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// normalizePhone strips non-digit chars and prepends +1 if no country code. +func normalizePhone(raw string) string { + var digits strings.Builder + for _, c := range strings.TrimSpace(raw) { + if c >= '0' && c <= '9' { + digits.WriteRune(c) + } + } + d := digits.String() + if d == "" { + return "" + } + // If 10 digits, assume US and prepend 1 + if len(d) == 10 { + d = "1" + d + } + return "+" + d +} + +func sendVerificationSMS(to, code string) error { + sid := os.Getenv("TWILIO_ACCOUNT_SID") + token := os.Getenv("TWILIO_AUTH_TOKEN") + from := os.Getenv("TWILIO_FROM_NUMBER") + + if sid == "" || token == "" || from == "" { + log.Printf("Twilio not configured — code for %s is: %s", to, code) + return nil + } + + body := fmt.Sprintf("Your bbq login code: %s", code) + apiURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", sid) + + data := url.Values{} + data.Set("To", to) + data.Set("From", from) + data.Set("Body", body) + + req, _ := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode())) + req.SetBasicAuth(sid, token) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("twilio API returned %d", resp.StatusCode) + } + return nil +} diff --git a/db/models.go b/db/models.go index 84ea7aa..0ec6ab4 100644 --- a/db/models.go +++ b/db/models.go @@ -5,6 +5,7 @@ package db import ( + "database/sql" "time" ) @@ -25,6 +26,7 @@ type Event struct { Location string AdminToken string Description string + UserID sql.NullInt64 CreatedAt time.Time } @@ -36,6 +38,12 @@ type Rsvp struct { CreatedAt time.Time } +type Session struct { + Token string + UserID int64 + ExpiresAt time.Time +} + type Slot struct { ID int64 EventID int64 @@ -44,3 +52,18 @@ type Slot struct { MaxClaims int64 SortOrder int64 } + +type User struct { + ID int64 + Phone string + Name string + CreatedAt time.Time +} + +type VerificationCode struct { + ID int64 + Phone string + Code string + ExpiresAt time.Time + Used int64 +} diff --git a/db/queries.sql b/db/queries.sql index e7e8a96..08d92f3 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -68,3 +68,41 @@ DELETE FROM rsvps WHERE id = ?; -- name: CountRsvps :one SELECT COUNT(*) FROM rsvps WHERE event_id = ?; + +-- name: GetUserByPhone :one +SELECT * FROM users WHERE phone = ?; + +-- name: CreateUser :one +INSERT INTO users (phone, name) VALUES (?, '') RETURNING *; + +-- name: UpdateUserName :exec +UPDATE users SET name = ? WHERE id = ?; + +-- name: CreateSession :exec +INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?); + +-- name: GetSession :one +SELECT * FROM sessions WHERE token = ? AND expires_at > CURRENT_TIMESTAMP; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE token = ?; + +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP; + +-- name: CreateVerificationCode :exec +INSERT INTO verification_codes (phone, code, expires_at) VALUES (?, ?, ?); + +-- name: GetVerificationCode :one +SELECT * FROM verification_codes +WHERE phone = ? AND code = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP +ORDER BY id DESC LIMIT 1; + +-- name: MarkVerificationCodeUsed :exec +UPDATE verification_codes SET used = 1 WHERE id = ?; + +-- name: ListEventsByUser :many +SELECT * FROM events WHERE user_id = ? ORDER BY created_at DESC; + +-- name: SetEventUser :exec +UPDATE events SET user_id = ? WHERE id = ?; diff --git a/db/queries.sql.go b/db/queries.sql.go index 512062b..57c616a 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -7,6 +7,8 @@ package db import ( "context" + "database/sql" + "time" ) const countClaimsBySlot = `-- name: CountClaimsBySlot :one @@ -59,7 +61,7 @@ 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, created_at +RETURNING id, slug, title, date, time, location, admin_token, description, user_id, created_at ` type CreateEventParams struct { @@ -92,6 +94,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event &i.Location, &i.AdminToken, &i.Description, + &i.UserID, &i.CreatedAt, ) return i, err @@ -122,6 +125,21 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e return i, err } +const createSession = `-- name: CreateSession :exec +INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?) +` + +type CreateSessionParams struct { + Token string + UserID int64 + ExpiresAt time.Time +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error { + _, err := q.db.ExecContext(ctx, createSession, arg.Token, arg.UserID, arg.ExpiresAt) + return err +} + const createSlot = `-- name: CreateSlot :one INSERT INTO slots (event_id, name, emoji, max_claims, sort_order) VALUES (?, ?, ?, ?, ?) @@ -156,6 +174,37 @@ func (q *Queries) CreateSlot(ctx context.Context, arg CreateSlotParams) (Slot, e return i, err } +const createUser = `-- name: CreateUser :one +INSERT INTO users (phone, name) VALUES (?, '') RETURNING id, phone, name, created_at +` + +func (q *Queries) CreateUser(ctx context.Context, phone string) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, phone) + var i User + err := row.Scan( + &i.ID, + &i.Phone, + &i.Name, + &i.CreatedAt, + ) + return i, err +} + +const createVerificationCode = `-- name: CreateVerificationCode :exec +INSERT INTO verification_codes (phone, code, expires_at) VALUES (?, ?, ?) +` + +type CreateVerificationCodeParams struct { + Phone string + Code string + ExpiresAt time.Time +} + +func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) error { + _, err := q.db.ExecContext(ctx, createVerificationCode, arg.Phone, arg.Code, arg.ExpiresAt) + return err +} + const deleteClaim = `-- name: DeleteClaim :exec DELETE FROM claims WHERE id = ? ` @@ -174,6 +223,15 @@ func (q *Queries) DeleteEvent(ctx context.Context, id int64) error { return err } +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteExpiredSessions) + return err +} + const deleteRsvp = `-- name: DeleteRsvp :exec DELETE FROM rsvps WHERE id = ? ` @@ -183,6 +241,15 @@ func (q *Queries) DeleteRsvp(ctx context.Context, id int64) error { return err } +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions WHERE token = ? +` + +func (q *Queries) DeleteSession(ctx context.Context, token string) error { + _, err := q.db.ExecContext(ctx, deleteSession, token) + return err +} + const deleteSlot = `-- name: DeleteSlot :exec DELETE FROM slots WHERE id = ? ` @@ -193,7 +260,7 @@ func (q *Queries) DeleteSlot(ctx context.Context, id int64) error { } const getEventByAdminToken = `-- name: GetEventByAdminToken :one -SELECT id, slug, title, date, time, location, admin_token, description, created_at FROM events WHERE admin_token = ? +SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE admin_token = ? ` func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) { @@ -208,13 +275,14 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) ( &i.Location, &i.AdminToken, &i.Description, + &i.UserID, &i.CreatedAt, ) return i, err } const getEventBySlug = `-- name: GetEventBySlug :one -SELECT id, slug, title, date, time, location, admin_token, description, created_at FROM events WHERE slug = ? +SELECT id, slug, title, date, time, location, admin_token, description, user_id, created_at FROM events WHERE slug = ? ` func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) { @@ -229,11 +297,23 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error &i.Location, &i.AdminToken, &i.Description, + &i.UserID, &i.CreatedAt, ) return i, err } +const getSession = `-- name: GetSession :one +SELECT token, user_id, expires_at FROM sessions WHERE token = ? AND expires_at > CURRENT_TIMESTAMP +` + +func (q *Queries) GetSession(ctx context.Context, token string) (Session, error) { + row := q.db.QueryRowContext(ctx, getSession, token) + var i Session + err := row.Scan(&i.Token, &i.UserID, &i.ExpiresAt) + return i, err +} + const getSlot = `-- name: GetSlot :one SELECT id, event_id, name, emoji, max_claims, sort_order FROM slots WHERE id = ? ` @@ -252,6 +332,46 @@ func (q *Queries) GetSlot(ctx context.Context, id int64) (Slot, error) { return i, err } +const getUserByPhone = `-- name: GetUserByPhone :one +SELECT id, phone, name, created_at FROM users WHERE phone = ? +` + +func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByPhone, phone) + var i User + err := row.Scan( + &i.ID, + &i.Phone, + &i.Name, + &i.CreatedAt, + ) + return i, err +} + +const getVerificationCode = `-- name: GetVerificationCode :one +SELECT id, phone, code, expires_at, used FROM verification_codes +WHERE phone = ? AND code = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP +ORDER BY id DESC LIMIT 1 +` + +type GetVerificationCodeParams struct { + Phone string + Code string +} + +func (q *Queries) GetVerificationCode(ctx context.Context, arg GetVerificationCodeParams) (VerificationCode, error) { + row := q.db.QueryRowContext(ctx, getVerificationCode, arg.Phone, arg.Code) + var i VerificationCode + err := row.Scan( + &i.ID, + &i.Phone, + &i.Code, + &i.ExpiresAt, + &i.Used, + ) + return i, err +} + const listClaimsByEvent = `-- name: ListClaimsByEvent :many SELECT c.id, c.slot_id, c.name, c.note, c.created_at FROM claims c JOIN slots s ON c.slot_id = s.id @@ -321,6 +441,44 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim, return items, nil } +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 +` + +func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([]Event, error) { + rows, err := q.db.QueryContext(ctx, listEventsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Event + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Title, + &i.Date, + &i.Time, + &i.Location, + &i.AdminToken, + &i.Description, + &i.UserID, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listRsvps = `-- name: ListRsvps :many SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at ` @@ -388,6 +546,29 @@ func (q *Queries) ListSlots(ctx context.Context, eventID int64) ([]Slot, error) return items, nil } +const markVerificationCodeUsed = `-- name: MarkVerificationCodeUsed :exec +UPDATE verification_codes SET used = 1 WHERE id = ? +` + +func (q *Queries) MarkVerificationCodeUsed(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, markVerificationCodeUsed, id) + return err +} + +const setEventUser = `-- name: SetEventUser :exec +UPDATE events SET user_id = ? WHERE id = ? +` + +type SetEventUserParams struct { + UserID sql.NullInt64 + ID int64 +} + +func (q *Queries) SetEventUser(ctx context.Context, arg SetEventUserParams) error { + _, err := q.db.ExecContext(ctx, setEventUser, arg.UserID, arg.ID) + return err +} + const updateEvent = `-- name: UpdateEvent :exec UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ? ` @@ -445,3 +626,17 @@ func (q *Queries) UpdateSlot(ctx context.Context, arg UpdateSlotParams) error { ) return err } + +const updateUserName = `-- name: UpdateUserName :exec +UPDATE users SET name = ? WHERE id = ? +` + +type UpdateUserNameParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdateUserName(ctx context.Context, arg UpdateUserNameParams) error { + _, err := q.db.ExecContext(ctx, updateUserName, arg.Name, arg.ID) + return err +} diff --git a/features.go b/features.go new file mode 100644 index 0000000..1ea6c02 --- /dev/null +++ b/features.go @@ -0,0 +1,18 @@ +package main + +import "strings" + +type Features struct { + Auth bool +} + +func parseFeatures(env string) Features { + var f Features + for _, flag := range strings.Split(env, ",") { + switch strings.TrimSpace(strings.ToLower(flag)) { + case "auth": + f.Auth = true + } + } + return f +} diff --git a/handlers.go b/handlers.go index b9bf4f4..000830c 100644 --- a/handlers.go +++ b/handlers.go @@ -49,7 +49,10 @@ func randomSlug() string { // --- Home / Create Event --- func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { - pageTmpl["home"].ExecuteTemplate(w, "layout", nil) + pageTmpl["home"].ExecuteTemplate(w, "layout", map[string]any{ + "User": s.currentUser(r), + "AuthEnabled": s.features.Auth, + }) } func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { @@ -77,6 +80,14 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { return } + // Associate event with logged-in user + if user := s.currentUser(r); user != nil { + s.q.SetEventUser(r.Context(), db.SetEventUserParams{ + UserID: sql.NullInt64{Int64: user.ID, Valid: true}, + ID: event.ID, + }) + } + slotNames := r.Form["slot_name"] slotEmojis := r.Form["slot_emoji"] slotMaxes := r.Form["slot_max"] @@ -136,6 +147,8 @@ type EventPageData struct { IsAdmin bool BaseURL string DescriptionHTML template.HTML + User *db.User + AuthEnabled bool } func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { @@ -215,6 +228,8 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve IsAdmin: isAdmin, BaseURL: s.baseURL, DescriptionHTML: descHTML, + User: s.currentUser(r), + AuthEnabled: s.features.Auth, }, nil } diff --git a/main.go b/main.go index 48ccdce..2f9d183 100644 --- a/main.go +++ b/main.go @@ -32,21 +32,23 @@ var schemaSQL string var pageTmpl map[string]*template.Template type Server struct { - q *db.Queries - db *sql.DB - baseURL string + q *db.Queries + db *sql.DB + baseURL string + features Features // SSE: map of event slug -> set of channels mu sync.Mutex clients map[string]map[chan struct{}]struct{} } -func NewServer(database *sql.DB, baseURL string) *Server { +func NewServer(database *sql.DB, baseURL string, features Features) *Server { return &Server{ - q: db.New(database), - db: database, - baseURL: baseURL, - clients: make(map[string]map[chan struct{}]struct{}), + q: db.New(database), + db: database, + baseURL: baseURL, + features: features, + clients: make(map[string]map[chan struct{}]struct{}), } } @@ -93,6 +95,7 @@ func main() { port = v } baseURL := os.Getenv("BBQ_BASE_URL") // e.g. https://bbq.torrtle.co + features := parseFeatures(os.Getenv("BBQ_FEATURES")) database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on") if err != nil { @@ -121,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"} { + for _, page := range []string{"home", "event", "login", "dashboard", "name"} { files := append([]string{"templates/" + page + ".html"}, shared...) pageTmpl[page] = template.Must( template.New("").Funcs(funcMap).ParseFS(templateFS, files...), @@ -132,15 +135,30 @@ func main() { template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"), ) - srv := NewServer(database, baseURL) + srv := NewServer(database, baseURL, features) r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(srv.sessionMiddleware) // Static files r.Handle("/static/*", http.FileServer(http.FS(staticFS))) + // Auth (conditional on feature flag) + if features.Auth { + r.Get("/login", srv.handleLoginPage) + r.Post("/login", srv.handleLoginSubmit) + r.Post("/login/verify", srv.handleVerifyCode) + r.Post("/logout", srv.handleLogout) + r.Group(func(r chi.Router) { + r.Use(srv.requireAuth) + r.Get("/dashboard", srv.handleDashboard) + r.Get("/account/name", srv.handleNamePage) + r.Post("/account/name", srv.handleNameSubmit) + }) + } + // Home / create event r.Get("/", srv.handleHome) r.Post("/events", srv.handleCreateEvent) diff --git a/migrate.go b/migrate.go index d716cdc..74d1fb7 100644 --- a/migrate.go +++ b/migrate.go @@ -7,11 +7,20 @@ import ( ) func runMigrations(database *sql.DB) { - _, err := database.Exec(`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`) - if err != nil { - if strings.Contains(err.Error(), "duplicate column name") { - return + migrations := []string{ + `ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`, + `ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`, + `ALTER TABLE users RENAME COLUMN email TO phone`, + `ALTER TABLE verification_codes RENAME COLUMN email TO phone`, + } + for _, m := range migrations { + _, err := database.Exec(m) + if err != nil { + if strings.Contains(err.Error(), "duplicate column name") || + strings.Contains(err.Error(), "no such column") { + continue + } + log.Printf("migration warning: %v", err) } - log.Printf("migration warning: %v", err) } } diff --git a/schema.sql b/schema.sql index 27d7d79..4067858 100644 --- a/schema.sql +++ b/schema.sql @@ -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 '', + user_id INTEGER REFERENCES users(id), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -35,6 +36,29 @@ CREATE TABLE IF NOT EXISTS rsvps ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS verification_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL, + code TEXT NOT NULL, + expires_at DATETIME NOT NULL, + used INTEGER NOT NULL DEFAULT 0 +); + CREATE INDEX IF NOT EXISTS idx_slots_event ON slots(event_id); CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_id); CREATE INDEX IF NOT EXISTS idx_rsvps_event ON rsvps(event_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_verification_codes_phone ON verification_codes(phone); diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..ad96371 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,37 @@ +{{template "layout" .}} + +{{define "title"}}bbq — my events{{end}} + +{{define "content"}} +
+
Dashboard
+

My events

+

+ Welcome back, {{.User.Name}}. +

+
+ +
+ + Create event +
+ +{{if .Events}} +
+ {{range .Events}} +
+
+
{{.Title}}
+
+ {{.Date}} · {{.Time}} · {{.Location}} +
+
+
+ Manage +
+
+ {{end}} +
+{{else}} +

No events yet. Create one above!

+{{end}} +{{end}} diff --git a/templates/event.html b/templates/event.html index cf034d0..2b6c795 100644 --- a/templates/event.html +++ b/templates/event.html @@ -49,7 +49,7 @@ hx-on::after-request="if(event.detail.successful) this.reset()">
- +
@@ -70,7 +70,7 @@ hx-on::after-request="if(event.detail.successful) this.reset()">
- +
diff --git a/templates/layout.html b/templates/layout.html index b7790ea..fa8950f 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -386,6 +386,9 @@ {{block "admin-bar" .}}{{end}}
+
{{block "content" .}}{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..615a4a8 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,42 @@ +{{template "layout" .}} + +{{define "title"}}bbq — log in{{end}} + +{{define "content"}} +
+
Account
+

Log in

+

+ Enter your phone number to receive a login code. +

+
+ +
+ {{if eq .Step "phone"}} +
+
+ + +
+ +
+ {{else}} +
+ +

+ Code sent to {{.Phone}} +

+ {{if .Error}} +

{{.Error}}

+ {{end}} +
+ + +
+ +
+ {{end}} +
+{{end}} diff --git a/templates/name.html b/templates/name.html new file mode 100644 index 0000000..4e2ca56 --- /dev/null +++ b/templates/name.html @@ -0,0 +1,24 @@ +{{template "layout" .}} + +{{define "title"}}bbq — your name{{end}} + +{{define "content"}} +
+
Account
+

What's your name?

+

+ This will be used to autofill your name when you RSVP or claim a slot. +

+
+ +
+
+
+ + +
+ +
+
+{{end}}