package main import ( "bytes" "crypto/rand" "database/sql" "encoding/hex" "fmt" "log" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/ryanchen/bbq/db" ) func randomToken() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } func randomSlug() string { b := make([]byte, 4) rand.Read(b) return hex.EncodeToString(b) } // --- Home / Create Event --- func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { pageTmpl["home"].ExecuteTemplate(w, "layout", nil) } func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { r.ParseForm() title := strings.TrimSpace(r.FormValue("title")) date := strings.TrimSpace(r.FormValue("date")) time_ := strings.TrimSpace(r.FormValue("time")) location := strings.TrimSpace(r.FormValue("location")) if title == "" { http.Error(w, "Title is required", http.StatusBadRequest) return } 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, }) if err != nil { log.Printf("create event: %v", err) http.Error(w, "Failed to create event", http.StatusInternalServerError) return } // Parse slot fields: slots like "drinks", "salad", etc. slotNames := r.Form["slot_name"] slotEmojis := r.Form["slot_emoji"] slotMaxes := r.Form["slot_max"] for i, name := range slotNames { name = strings.TrimSpace(name) if name == "" { continue } emoji := "" if i < len(slotEmojis) { emoji = strings.TrimSpace(slotEmojis[i]) } maxClaims := int64(1) if i < len(slotMaxes) { if v, err := strconv.ParseInt(slotMaxes[i], 10, 64); err == nil && v > 0 { maxClaims = v } } s.q.CreateSlot(r.Context(), db.CreateSlotParams{ EventID: event.ID, Name: name, Emoji: emoji, MaxClaims: maxClaims, SortOrder: int64(i), }) } http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther) } // --- Guest Event View --- type SlotView struct { Slot db.Slot Claims []db.Claim ClaimCount int64 IsFull bool Pct int64 } type EventPageData struct { Event db.Event Slots []SlotView TotalGoing int64 IsAdmin bool } func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil { return nil, err } slots, err := s.q.ListSlots(r.Context(), event.ID) if err != nil { return nil, err } claims, err := s.q.ListClaimsByEvent(r.Context(), event.ID) if err != nil { return nil, err } claimsBySlot := make(map[int64][]db.Claim) for _, c := range claims { claimsBySlot[c.SlotID] = append(claimsBySlot[c.SlotID], c) } var slotViews []SlotView var totalGoing int64 for _, slot := range slots { sc := claimsBySlot[slot.ID] count := int64(len(sc)) totalGoing += count pct := int64(0) if slot.MaxClaims > 0 { pct = (count * 100) / slot.MaxClaims if pct > 100 { pct = 100 } } slotViews = append(slotViews, SlotView{ Slot: slot, Claims: sc, ClaimCount: count, IsFull: count >= slot.MaxClaims, Pct: pct, }) } return &EventPageData{ Event: event, Slots: slotViews, TotalGoing: totalGoing, IsAdmin: isAdmin, }, nil } func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") data, err := s.loadEventPage(r, slug, false) if err != nil { if err == sql.ErrNoRows { http.NotFound(w, r) return } log.Printf("load event: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } pageTmpl["event"].ExecuteTemplate(w, "layout", data) } func (s *Server) handleSlotsPartial(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "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) } // --- Claim / Unclaim --- func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") r.ParseForm() slotID, err := strconv.ParseInt(r.FormValue("slot_id"), 10, 64) if err != nil { http.Error(w, "Invalid slot", http.StatusBadRequest) return } name := strings.TrimSpace(r.FormValue("name")) if name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } note := strings.TrimSpace(r.FormValue("note")) // Check slot exists and belongs to this event slot, err := s.q.GetSlot(r.Context(), slotID) if err != nil { http.Error(w, "Slot not found", http.StatusNotFound) return } event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil || slot.EventID != event.ID { http.Error(w, "Invalid slot", http.StatusBadRequest) return } // Check not full count, err := s.q.CountClaimsBySlot(r.Context(), slotID) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } if count >= slot.MaxClaims { http.Error(w, "Slot is full", http.StatusConflict) return } _, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{ SlotID: slotID, Name: name, Note: note, }) if err != nil { log.Printf("create claim: %v", err) http.Error(w, "Failed to claim", http.StatusInternalServerError) return } s.notify(slug) // Return updated slots partial 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) handleUnclaim(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") claimID, err := strconv.ParseInt(chi.URLParam(r, "claimID"), 10, 64) if err != nil { http.Error(w, "Invalid claim", http.StatusBadRequest) return } err = s.q.DeleteClaim(r.Context(), claimID) if err != nil { http.Error(w, "Failed to remove", 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) } // --- SSE --- func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "SSE not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ch := s.subscribe(slug) defer s.unsubscribe(slug, ch) for { select { case <-ch: data, err := s.loadEventPage(r, slug, false) if err != nil { return } var buf bytes.Buffer pageTmpl["slots"].ExecuteTemplate(&buf, "slots-inner", data) // SSE format: replace newlines for event stream lines := strings.Split(buf.String(), "\n") for _, line := range lines { fmt.Fprintf(w, "data: %s\n", line) } fmt.Fprintf(w, "\n") flusher.Flush() case <-r.Context().Done(): return } } } // --- Admin --- func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") token := chi.URLParam(r, "token") event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil || event.AdminToken != token { http.NotFound(w, r) return } data, err := s.loadEventPage(r, slug, true) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["event"].ExecuteTemplate(w, "layout", data) } func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") token := chi.URLParam(r, "token") event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil || event.AdminToken != token { http.NotFound(w, r) return } r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) emoji := strings.TrimSpace(r.FormValue("emoji")) maxClaims := int64(1) if v, err := strconv.ParseInt(r.FormValue("max_claims"), 10, 64); err == nil && v > 0 { maxClaims = v } if name == "" { http.Error(w, "Name required", http.StatusBadRequest) return } _, err = s.q.CreateSlot(r.Context(), db.CreateSlotParams{ EventID: event.ID, Name: name, Emoji: emoji, MaxClaims: maxClaims, SortOrder: 999, }) if err != nil { http.Error(w, "Failed", http.StatusInternalServerError) return } s.notify(slug) data, err := s.loadEventPage(r, slug, true) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) } func (s *Server) handleDeleteSlot(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") token := chi.URLParam(r, "token") event, err := s.q.GetEventBySlug(r.Context(), slug) if err != nil || event.AdminToken != token { http.NotFound(w, r) return } slotID, err := strconv.ParseInt(chi.URLParam(r, "slotID"), 10, 64) if err != nil { http.Error(w, "Invalid slot", http.StatusBadRequest) return } s.q.DeleteSlot(r.Context(), slotID) s.notify(slug) data, err := s.loadEventPage(r, slug, true) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) }