Switch to phone auth via Twilio SMS and add feature flag system

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:45:15 -04:00
parent b3203a7506
commit 471cc3ad8c
15 changed files with 820 additions and 21 deletions
+73
View File
@@ -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.
+280
View File
@@ -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
}
+23
View File
@@ -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
}
+38
View File
@@ -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 = ?;
+198 -3
View File
@@ -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
}
+18
View File
@@ -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
}
+16 -1
View File
@@ -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
}
+28 -10
View File
@@ -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)
+14 -5
View File
@@ -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)
}
}
+24
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 '',
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);
+37
View File
@@ -0,0 +1,37 @@
{{template "layout" .}}
{{define "title"}}bbq — my events{{end}}
{{define "content"}}
<div class="event-header">
<div class="event-tag">Dashboard</div>
<h1 class="event-title">My events</h1>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-top:8px;">
Welcome back, {{.User.Name}}.
</p>
</div>
<div style="margin-bottom:24px;">
<a href="/" class="btn-claim" style="text-decoration:none;display:inline-block;">+ Create event</a>
</div>
{{if .Events}}
<div class="slots-grid">
{{range .Events}}
<div class="slot-card">
<div class="slot-info">
<div class="slot-name">{{.Title}}</div>
<div style="font-family:'DM Mono',monospace;font-size:0.72rem;color:#555;">
{{.Date}} &middot; {{.Time}} &middot; {{.Location}}
</div>
</div>
<div class="slot-right">
<a href="/e/{{.Slug}}/admin/{{.AdminToken}}" class="btn-claim" style="text-decoration:none;">Manage</a>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="nobody" style="text-align:center;padding:40px 0;">No events yet. Create one above!</p>
{{end}}
{{end}}
+2 -2
View File
@@ -49,7 +49,7 @@
hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="form-row">
<label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required>
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
</div>
<div class="form-row">
<label>Note (optional)</label>
@@ -70,7 +70,7 @@
hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="form-row">
<label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required>
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
</div>
<div class="form-row">
<label>Slot</label>
+3
View File
@@ -386,6 +386,9 @@
{{block "admin-bar" .}}{{end}}
<header>
<a class="logo" href="/"><span>&#127834;</span> bbq</a>
<nav style="font-family:'DM Mono',monospace;font-size:0.75rem;">
{{if .User}}<a href="/dashboard" style="color:var(--ink);text-decoration:none;margin-right:12px;">{{.User.Name}}</a><form method="POST" action="/logout" style="display:inline;"><button type="submit" style="background:none;border:none;font-family:inherit;font-size:inherit;cursor:pointer;text-decoration:underline;">log out</button></form>{{else if .AuthEnabled}}<a href="/login" style="color:var(--ink);text-decoration:underline;">log in</a>{{end}}
</nav>
</header>
<main>
{{block "content" .}}{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{template "layout" .}}
{{define "title"}}bbq — log in{{end}}
{{define "content"}}
<div class="event-header">
<div class="event-tag">Account</div>
<h1 class="event-title">Log in</h1>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-top:8px;">
Enter your phone number to receive a login code.
</p>
</div>
<div class="claim-form-wrapper">
{{if eq .Step "phone"}}
<form method="POST" action="/login">
<div class="form-row">
<label>Phone</label>
<input type="tel" name="phone" placeholder="(555) 123-4567" required autofocus>
</div>
<button class="btn-submit" type="submit">Send code &#8599;</button>
</form>
{{else}}
<form method="POST" action="/login/verify">
<input type="hidden" name="phone" value="{{.Phone}}">
<p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;">
Code sent to <strong>{{.Phone}}</strong>
</p>
{{if .Error}}
<p style="color:#c44;font-size:0.85rem;margin-bottom:12px;">{{.Error}}</p>
{{end}}
<div class="form-row">
<label>6-digit code</label>
<input type="text" name="code" placeholder="000000" required autofocus
inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
style="font-family:'DM Mono',monospace;font-size:1.5rem;letter-spacing:8px;text-align:center;">
</div>
<button class="btn-submit" type="submit">Verify &#8599;</button>
</form>
{{end}}
</div>
{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{template "layout" .}}
{{define "title"}}bbq — your name{{end}}
{{define "content"}}
<div class="event-header">
<div class="event-tag">Account</div>
<h1 class="event-title">What's your name?</h1>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-top:8px;">
This will be used to autofill your name when you RSVP or claim a slot.
</p>
</div>
<div class="claim-form-wrapper">
<form method="POST" action="/account/name">
<div class="form-row">
<label>Name</label>
<input type="text" name="name" placeholder="Your name" required autofocus
value="{{if .User}}{{.User.Name}}{{end}}">
</div>
<button class="btn-submit" type="submit">Save &#8599;</button>
</form>
</div>
{{end}}