diff --git a/db/models.go b/db/models.go index 4f976f1..a5f25c0 100644 --- a/db/models.go +++ b/db/models.go @@ -27,6 +27,14 @@ type Event struct { CreatedAt time.Time } +type Rsvp struct { + ID int64 + EventID int64 + Name string + Note string + CreatedAt time.Time +} + type Slot struct { ID int64 EventID int64 diff --git a/db/queries.sql b/db/queries.sql index be6df74..b2ab4e8 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -51,3 +51,17 @@ DELETE FROM claims WHERE id = ?; -- name: CountClaimsBySlot :one SELECT COUNT(*) FROM claims WHERE slot_id = ?; + +-- name: ListRsvps :many +SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at; + +-- name: CreateRsvp :one +INSERT INTO rsvps (event_id, name, note) +VALUES (?, ?, ?) +RETURNING *; + +-- name: DeleteRsvp :exec +DELETE FROM rsvps WHERE id = ?; + +-- name: CountRsvps :one +SELECT COUNT(*) FROM rsvps WHERE event_id = ?; diff --git a/db/queries.sql.go b/db/queries.sql.go index 4d97436..6232f17 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -20,6 +20,17 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e return count, err } +const countRsvps = `-- name: CountRsvps :one +SELECT COUNT(*) FROM rsvps WHERE event_id = ? +` + +func (q *Queries) CountRsvps(ctx context.Context, eventID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countRsvps, eventID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createClaim = `-- name: CreateClaim :one INSERT INTO claims (slot_id, name, note) VALUES (?, ?, ?) @@ -83,6 +94,31 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event return i, err } +const createRsvp = `-- name: CreateRsvp :one +INSERT INTO rsvps (event_id, name, note) +VALUES (?, ?, ?) +RETURNING id, event_id, name, note, created_at +` + +type CreateRsvpParams struct { + EventID int64 + Name string + Note string +} + +func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) { + row := q.db.QueryRowContext(ctx, createRsvp, arg.EventID, arg.Name, arg.Note) + var i Rsvp + err := row.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Note, + &i.CreatedAt, + ) + return i, err +} + const createSlot = `-- name: CreateSlot :one INSERT INTO slots (event_id, name, emoji, max_claims, sort_order) VALUES (?, ?, ?, ?, ?) @@ -135,6 +171,15 @@ func (q *Queries) DeleteEvent(ctx context.Context, id int64) error { return err } +const deleteRsvp = `-- name: DeleteRsvp :exec +DELETE FROM rsvps WHERE id = ? +` + +func (q *Queries) DeleteRsvp(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteRsvp, id) + return err +} + const deleteSlot = `-- name: DeleteSlot :exec DELETE FROM slots WHERE id = ? ` @@ -271,6 +316,39 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim, 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 +` + +func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) { + rows, err := q.db.QueryContext(ctx, listRsvps, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Rsvp + for rows.Next() { + var i Rsvp + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Note, + &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 listSlots = `-- name: ListSlots :many SELECT id, event_id, name, emoji, max_claims, sort_order FROM slots WHERE event_id = ? ORDER BY sort_order, id ` diff --git a/handlers.go b/handlers.go index dc5debd..0f949f9 100644 --- a/handlers.go +++ b/handlers.go @@ -96,6 +96,7 @@ type SlotView struct { type EventPageData struct { Event db.Event Slots []SlotView + Rsvps []db.Rsvp TotalGoing int64 IsAdmin bool } @@ -143,9 +144,16 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve }) } + rsvps, err := s.q.ListRsvps(r.Context(), event.ID) + if err != nil { + return nil, err + } + totalGoing += int64(len(rsvps)) + return &EventPageData{ Event: event, Slots: slotViews, + Rsvps: rsvps, TotalGoing: totalGoing, IsAdmin: isAdmin, }, nil @@ -262,6 +270,63 @@ func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) { pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } +// --- RSVP --- + +func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + r.ParseForm() + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + note := strings.TrimSpace(r.FormValue("note")) + + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil { + http.Error(w, "Event not found", http.StatusNotFound) + return + } + + _, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{ + EventID: event.ID, Name: name, Note: note, + }) + if err != nil { + log.Printf("create rsvp: %v", err) + http.Error(w, "Failed", http.StatusInternalServerError) + return + } + + s.notify(slug) + + data, err := s.loadEventPage(r, slug, false) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} + +func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64) + if err != nil { + http.Error(w, "Invalid RSVP", http.StatusBadRequest) + return + } + + s.q.DeleteRsvp(r.Context(), rsvpID) + s.notify(slug) + + data, err := s.loadEventPage(r, slug, false) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} + // --- SSE --- func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { diff --git a/main.go b/main.go index 9e81f43..de7eea8 100644 --- a/main.go +++ b/main.go @@ -146,6 +146,8 @@ func main() { r.Get("/e/{slug}/slots", srv.handleSlotsPartial) r.Post("/e/{slug}/claim", srv.handleClaim) r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim) + r.Post("/e/{slug}/rsvp", srv.handleRsvp) + r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp) // SSE r.Get("/e/{slug}/sse", srv.handleSSE) diff --git a/schema.sql b/schema.sql index 149e9b4..440fa0f 100644 --- a/schema.sql +++ b/schema.sql @@ -26,5 +26,14 @@ CREATE TABLE IF NOT EXISTS claims ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS rsvps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, + name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + 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); diff --git a/templates/event.html b/templates/event.html index d02529d..3cc2fe8 100644 --- a/templates/event.html +++ b/templates/event.html @@ -2,6 +2,15 @@ {{define "title"}}{{.Event.Title}} — bbq{{end}} +{{define "meta"}} + + + + + + +{{end}} + {{define "admin-bar"}}{{if .IsAdmin}}
ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}
{{end}}{{end}} {{define "content"}} @@ -15,8 +24,6 @@ -
What's needed
-
-
Add your name
+
Sign up
-
I'll bring something →
+
I'm coming →
+
+
+ + +
+
+ + +
+ +
+
+ +{{if .Slots}} +
I'll bring something
+ +
+
Claim a slot →
Note (optional)
- +
+{{end}} {{if .IsAdmin}}
Admin: Add slot
diff --git a/templates/layout.html b/templates/layout.html index c70728c..dc684f1 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -4,6 +4,7 @@ {{block "title" .}}picnic{{end}} +{{block "meta" .}}{{end}} @@ -319,6 +320,12 @@ background: var(--yellow); border-color: var(--border); } + .rsvp-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 40px; + } .btn-danger { background: none; border: none; diff --git a/templates/slots.html b/templates/slots.html index ead1832..bff8576 100644 --- a/templates/slots.html +++ b/templates/slots.html @@ -1,4 +1,6 @@ {{define "slots-inner"}} +
What's needed
+
{{range .Slots}}
@@ -28,6 +30,25 @@
{{end}} +
+ +
Going ({{len .Rsvps}})
+
+ {{if .Rsvps}} + {{range .Rsvps}} + + {{.Name}}{{if .Note}} ({{.Note}}){{end}} + + + {{end}} + {{else}} + no one yet + {{end}} +
{{end}} {{define "slots.html"}}{{template "slots-inner" .}}{{end}}