package api import ( "context" "fmt" "html/template" "log" "net/http" "path/filepath" "sync" "time" "sprintpadawan/lib" ) type contextKey string const userKey contextKey = "user" type sseClient struct { ch chan string userID int } var ( roomClients = make(map[int][]*sseClient) clientsMu sync.Mutex ) var templates *template.Template // ==================== HELPERS ==================== func addSSEClient(roomID, userID int, ch chan string) { clientsMu.Lock() defer clientsMu.Unlock() roomClients[roomID] = append(roomClients[roomID], &sseClient{ch: ch, userID: userID}) } func removeSSEClient(roomID int, ch chan string) { clientsMu.Lock() defer clientsMu.Unlock() clients := roomClients[roomID] for i, c := range clients { if c.ch == ch { roomClients[roomID] = append(clients[:i], clients[i+1:]...) break } } if len(roomClients[roomID]) == 0 { delete(roomClients, roomID) } } func broadcast(roomID int, event string) { clientsMu.Lock() defer clientsMu.Unlock() alive := roomClients[roomID][:0] for _, c := range roomClients[roomID] { select { case c.ch <- event: alive = append(alive, c) default: // drop dead client } } roomClients[roomID] = alive } func GetConnectedUserIDs(roomID int) []int { clientsMu.Lock() defer clientsMu.Unlock() seen := make(map[int]bool) var ids []int for _, c := range roomClients[roomID] { if !seen[c.userID] { seen[c.userID] = true ids = append(ids, c.userID) } } return ids } func scaleToOptions(scale string) []string { switch scale { case "fibonacci": return []string{"1", "2", "3", "5", "8", "13", "21", "?"} case "tshirt": return []string{"XS", "S", "M", "L", "XL", "XXL", "?"} case "linear": return []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "?"} default: return []string{"1", "2", "3", "5", "8", "13", "21", "?"} } } func getRoomID(r *http.Request) int { return getPathInt(r, "id") } func getPathInt(r *http.Request, key string) int { var id int fmt.Sscanf(r.PathValue(key), "%d", &id) return id } type MemberView struct { Username string HasVoted bool ID int } type RoomData struct { *lib.Room User *lib.User Members []MemberView Stories []lib.Story IsOwner bool UserVotes map[int]string StoryVotes map[int][]lib.VoteView } // Builds data used by both full page and all partials func buildRoomData(room *lib.Room, user *lib.User) RoomData { members, _ := lib.GetRoomMembers(room.ID) stories, _ := lib.GetStoriesForRoom(room.ID) connectedIDs := GetConnectedUserIDs(room.ID) connectedMap := make(map[int]bool) for _, cid := range connectedIDs { connectedMap[cid] = true } connectedMap[user.ID] = true var activeVotes []lib.Vote if room.ActiveStoryID != nil { activeVotes, _ = lib.GetVotesForStory(*room.ActiveStoryID) } var memberViews []MemberView for _, m := range members { if !connectedMap[m.ID] { continue } hasVoted := false for _, v := range activeVotes { if v.UserID == m.ID { hasVoted = true break } } memberViews = append(memberViews, MemberView{ Username: m.Username, HasVoted: hasVoted, ID: m.ID, }) } userVotes := make(map[int]string) storyVotes := make(map[int][]lib.VoteView) for _, s := range stories { votes, _ := lib.GetVotesForStory(s.ID) for _, v := range votes { if v.UserID == user.ID { userVotes[s.ID] = v.Value } } if s.Voted { vv, _ := lib.GetVotesWithUsernames(s.ID) storyVotes[s.ID] = vv } } return RoomData{ Room: room, User: user, Members: memberViews, Stories: stories, IsOwner: room.OwnerID == user.ID, UserVotes: userVotes, StoryVotes: storyVotes, } } func init() { templates = template.Must(template.New("").Funcs(template.FuncMap{ "scaleToOptions": scaleToOptions, "derefInt": func(i *int) int { if i == nil { return 0 } return *i }, "slice": func(s string, start, end int) string { if len(s) == 0 { return "" } if start < 0 { start = 0 } if end > len(s) || end <= 0 { end = len(s) } if start > end { return "" } return s[start:end] }, "dict": func(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("odd number of arguments to dict") } d := make(map[string]interface{}) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } d[key] = values[i+1] } return d, nil }, }).ParseGlob(filepath.Join("templates", "*.html"))) } func SetupRoutes(mux *http.ServeMux) { mux.HandleFunc("/login", handleLogin) mux.HandleFunc("/register", handleRegister) mux.HandleFunc("/logout", handleLogout) mux.HandleFunc("/", requireAuth(handleIndex)) mux.HandleFunc("/rooms/new", requireAuth(handleNewRoom)) mux.HandleFunc("/rooms/create", requireAuth(handleCreateRoom)) mux.HandleFunc("/rooms/join", requireAuth(handleJoinRoom)) mux.HandleFunc("/rooms/{id}", requireAuth(handleRoom)) // HTMX partials mux.HandleFunc("/rooms/{id}/partial/stories", requireAuth(handlePartialStories)) mux.HandleFunc("/rooms/{id}/partial/members", requireAuth(handlePartialMembers)) mux.HandleFunc("/rooms/{id}/partial/vote-area", requireAuth(handlePartialVoteArea)) // Actions mux.HandleFunc("/rooms/{id}/stories/new", requireAuth(handleNewStoryForm)) mux.HandleFunc("/rooms/{id}/stories", requireAuth(handleAddStory)) mux.HandleFunc("/rooms/{id}/active", requireAuth(handleSetActiveStory)) mux.HandleFunc("/rooms/{id}/vote", requireAuth(handleVote)) mux.HandleFunc("/rooms/{id}/reveal", requireAuth(handleReveal)) mux.HandleFunc("/rooms/{id}/stories/{story_id}/edit", requireAuth(handleEditStoryForm)) mux.HandleFunc("/rooms/{id}/stories/{story_id}/rename", requireAuth(handleRenameStory)) mux.HandleFunc("/rooms/{id}/stories/{story_id}/delete", requireAuth(handleDeleteStory)) mux.HandleFunc("/rooms/{id}/stories/{story_id}/unreveal", requireAuth(handleUnrevealStory)) mux.HandleFunc("/sse/{room_id}", requireAuth(handleSSE)) } func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func renderTemplate(w http.ResponseWriter, name string, data interface{}) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := templates.ExecuteTemplate(w, name, data); err != nil { log.Printf("template error (%s): %v", name, err) http.Error(w, "internal server error", http.StatusInternalServerError) } } func renderRoomStories(w http.ResponseWriter, roomID int, user *lib.User) { room, err := lib.GetRoomByID(roomID) if err != nil { http.Error(w, "room not found", http.StatusNotFound) return } renderTemplate(w, "stories-panel", buildRoomData(room, user)) } func renderRoomMembers(w http.ResponseWriter, roomID int, user *lib.User) { room, err := lib.GetRoomByID(roomID) if err != nil { http.Error(w, "room not found", http.StatusNotFound) return } renderTemplate(w, "members-panel", buildRoomData(room, user)) } func requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_token") if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } user, err := lib.GetUserFromSession(cookie.Value) if err != nil || user == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } ctx := context.WithValue(r.Context(), userKey, user) next.ServeHTTP(w, r.WithContext(ctx)) } } func isLoggedIn(r *http.Request) bool { cookie, err := r.Cookie("session_token") if err != nil { return false } user, err := lib.GetUserFromSession(cookie.Value) return err == nil && user != nil } // ==================== AUTH HANDLERS ==================== func handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { if isLoggedIn(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } templates.ExecuteTemplate(w, "login.html", nil) return } username := r.FormValue("username") password := r.FormValue("password") user, err := lib.GetUserByUsername(username) if err != nil || !lib.CheckPasswordHash(password, user.PasswordHash) { templates.ExecuteTemplate(w, "login.html", map[string]string{"Error": "Invalid credentials"}) return } sessionID, _ := lib.CreateSession(user.ID) http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: sessionID, Expires: time.Now().Add(24 * time.Hour), HttpOnly: true, Path: "/", }) http.Redirect(w, r, "/", http.StatusSeeOther) } func handleRegister(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { if isLoggedIn(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } templates.ExecuteTemplate(w, "register.html", nil) return } username := r.FormValue("username") password := r.FormValue("password") confirm := r.FormValue("confirm_password") if password != confirm { templates.ExecuteTemplate(w, "register.html", map[string]string{"Error": "Passwords do not match"}) return } err := lib.CreateUser(username, password) if err != nil { templates.ExecuteTemplate(w, "register.html", map[string]string{"Error": "Username taken"}) return } http.Redirect(w, r, "/login", http.StatusSeeOther) } func handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_token") if err == nil { lib.DeleteSession(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: "", Expires: time.Now().Add(-1 * time.Hour), Path: "/", }) http.Redirect(w, r, "/login", http.StatusSeeOther) } // ==================== DASHBOARD ==================== func handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } user := r.Context().Value(userKey).(*lib.User) rooms, _ := lib.GetRoomsForUser(user.ID) w.Header().Set("Content-Type", "text/html; charset=utf-8") type RoomView struct { ID int Name string Code string Scale string IsOwner bool MemberCount int } data := struct { *lib.User Rooms []RoomView }{ User: user, } for _, room := range rooms { members, _ := lib.GetRoomMembers(room.ID) data.Rooms = append(data.Rooms, RoomView{ ID: room.ID, Name: room.Name, Code: room.Code, Scale: room.Scale, IsOwner: room.OwnerID == user.ID, MemberCount: len(members), }) } if err := templates.ExecuteTemplate(w, "index.html", data); err != nil { log.Printf("template error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) } } // ==================== ROOM CREATION / JOIN ==================== func handleNewRoom(w http.ResponseWriter, r *http.Request) { if err := templates.ExecuteTemplate(w, "room_form.html", nil); err != nil { log.Printf("template error: %v", err) } } func handleCreateRoom(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(userKey).(*lib.User) name := r.FormValue("name") scale := r.FormValue("scale") room, err := lib.CreateRoom(name, user.ID, scale) if err != nil { log.Printf("create room error: %v", err) http.Error(w, "failed to create room", http.StatusInternalServerError) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) } func handleJoinRoom(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(userKey).(*lib.User) code := r.FormValue("code") room, err := lib.GetRoomByCode(code) if err != nil { http.Error(w, "room not found", http.StatusNotFound) return } lib.AddUserToRoom(room.ID, user.ID) http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) } // ==================== MAIN ROOM & PARTIALS ==================== func handleRoom(w http.ResponseWriter, r *http.Request) { roomID := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) room, err := lib.GetRoomByID(roomID) if err != nil { http.NotFound(w, r) return } lib.AddUserToRoom(room.ID, user.ID) data := buildRoomData(room, user) renderTemplate(w, "room.html", data) } func handlePartialStories(w http.ResponseWriter, r *http.Request) { roomID := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) renderRoomStories(w, roomID, user) } func handlePartialMembers(w http.ResponseWriter, r *http.Request) { roomID := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) renderRoomMembers(w, roomID, user) } func handlePartialVoteArea(w http.ResponseWriter, r *http.Request) { roomID := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) room, err := lib.GetRoomByID(roomID) if err != nil { http.Error(w, "room not found", http.StatusNotFound) return } if room.ActiveStoryID == nil { w.WriteHeader(http.StatusNoContent) return } story, err := lib.GetStoryByID(*room.ActiveStoryID) if err != nil { http.Error(w, "story not found", http.StatusNotFound) return } data := struct { RoomData RoomData Story lib.Story }{ RoomData: buildRoomData(room, user), Story: *story, } renderTemplate(w, "vote-area", data) } // ==================== STORY & VOTING ACTIONS ==================== func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { roomID := getRoomID(r) room, _ := lib.GetRoomByID(roomID) renderTemplate(w, "story_form.html", room) } func handleAddStory(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) title := r.FormValue("title") _, err := lib.CreateStory(id, title) if err != nil { log.Printf("create story error: %v", err) http.Error(w, "failed to create story", http.StatusInternalServerError) return } broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) var sid int fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) room, _ := lib.GetRoomByID(id) if room.ActiveStoryID != nil { lib.UnrevealStory(*room.ActiveStoryID) lib.ClearVotesForStory(*room.ActiveStoryID) } lib.UnrevealStory(sid) lib.ClearVotesForStory(sid) lib.SetActiveStory(id, sid) broadcast(id, "stories") broadcast(id, "members") if isHTMX(r) { renderRoomStories(w, id, user) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleVote(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) value := r.FormValue("value") var sid int fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) lib.VoteOnStory(sid, user.ID, value) broadcast(id, "members") if story, err := lib.GetStoryByID(sid); err == nil && story.Voted { broadcast(id, "stories") } if r.Header.Get("HX-Request") == "true" { room, _ := lib.GetRoomByID(id) story, _ := lib.GetStoryByID(sid) tmplData := struct { RoomID int Story lib.Story Scale string UserVote string }{ RoomID: id, Story: *story, Scale: room.Scale, UserVote: value, } renderTemplate(w, "vote_form.html", tmplData) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleReveal(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) var sid int fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) lib.RevealVotes(sid) broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleUnrevealStory(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) var sid int fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) lib.UnrevealStory(sid) broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleEditStoryForm(w http.ResponseWriter, r *http.Request) { roomID := getRoomID(r) var storyID int fmt.Sscanf(r.PathValue("story_id"), "%d", &storyID) story, _ := lib.GetStoryByID(storyID) data := struct { RoomID int Story lib.Story }{ RoomID: roomID, Story: *story, } renderTemplate(w, "story_edit.html", data) } func handleRenameStory(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) var sid int fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) title := r.FormValue("title") lib.RenameStory(sid, title) broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleDeleteStory(w http.ResponseWriter, r *http.Request) { id := getRoomID(r) user := r.Context().Value(userKey).(*lib.User) var sid int fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) lib.DeleteStory(sid) broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) return } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } // ==================== SSE ==================== func handleSSE(w http.ResponseWriter, r *http.Request) { roomID := getPathInt(r, "room_id") user, ok := r.Context().Value(userKey).(*lib.User) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } ch := make(chan string, 10) addSSEClient(roomID, user.ID, ch) broadcast(roomID, "members") // notify others of new connection w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } notify := r.Context().Done() heartbeat := time.NewTicker(25 * time.Second) defer heartbeat.Stop() fmt.Fprint(w, ": connected\n\n") flusher.Flush() for { select { case <-notify: removeSSEClient(roomID, ch) broadcast(roomID, "members") return case <-heartbeat.C: fmt.Fprint(w, ": keep-alive\n\n") flusher.Flush() case event := <-ch: fmt.Fprintf(w, "event: %s\ndata: true\n\n", event) flusher.Flush() } } }