package api import ( "context" "fmt" "html/template" "log" "net/http" "path/filepath" "sync" "time" "sprintpadawan/lib" ) type PingResponse struct { Status string `json:"status"` Message string `json:"message"` } var templates *template.Template type contextKey string const userKey contextKey = "user" type sseClient struct { ch chan string userID int } var ( // roomClients maps roomID → list of connected SSE clients roomClients = make(map[int][]*sseClient) clientsMu sync.Mutex ) 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, message string) { clientsMu.Lock() defer clientsMu.Unlock() alive := roomClients[roomID][:0] for _, c := range roomClients[roomID] { select { case c.ch <- message: alive = append(alive, c) default: // drop dead client } } roomClients[roomID] = alive } // GetConnectedUserIDs returns deduplicated user IDs connected to a room 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 init() { templates = template.Must(template.New("").Funcs(template.FuncMap{ "scaleToOptions": scaleToOptions, "derefInt": func(i *int) int { if i == nil { return 0 } return *i }, }).ParseGlob(filepath.Join("templates", "*.html"))) } 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 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)) 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 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)) } } // isLoggedIn checks if the request has a valid session 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 } 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) } 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) } } 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) } func handleRoom(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(userKey).(*lib.User) var id int fmt.Sscanf(r.PathValue("id"), "%d", &id) room, err := lib.GetRoomByID(id) if err != nil { http.NotFound(w, r) return } lib.AddUserToRoom(room.ID, user.ID) members, _ := lib.GetRoomMembers(room.ID) stories, _ := lib.GetStoriesForRoom(room.ID) // Add HasVoted logic type MemberView struct { Username string HasVoted bool } var activeVotes []lib.Vote if room.ActiveStoryID != nil { activeVotes, _ = lib.GetVotesForStory(*room.ActiveStoryID) } // Filter to only connected members connectedIDs := GetConnectedUserIDs(room.ID) connectedMap := make(map[int]bool) for _, cid := range connectedIDs { connectedMap[cid] = true } // The current user loading the page will immediately connect to SSE connectedMap[user.ID] = true var memberViews []MemberView for _, m := range members { // Only include connected users 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, }) } 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 } } // For revealed stories, get votes with usernames if s.Voted { vv, _ := lib.GetVotesWithUsernames(s.ID) storyVotes[s.ID] = vv } } data := struct { *lib.Room User *lib.User Members []MemberView Stories []lib.Story IsOwner bool UserVotes map[int]string StoryVotes map[int][]lib.VoteView }{ Room: room, User: user, Members: memberViews, Stories: stories, IsOwner: room.OwnerID == user.ID, UserVotes: userVotes, StoryVotes: storyVotes, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := templates.ExecuteTemplate(w, "room.html", data); err != nil { log.Printf("template error: %v", err) } } func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { var id int fmt.Sscanf(r.PathValue("id"), "%d", &id) room, _ := lib.GetRoomByID(id) if err := templates.ExecuteTemplate(w, "story_form.html", room); err != nil { log.Printf("template error: %v", err) } } func handleAddStory(w http.ResponseWriter, r *http.Request) { var id int fmt.Sscanf(r.PathValue("id"), "%d", &id) title := r.FormValue("title") _, err := lib.CreateStory(id, title) if err != nil { log.Printf("create story error: %v", err) } broadcast(id, "refresh") http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { var id int fmt.Sscanf(r.PathValue("id"), "%d", &id) storyID := r.FormValue("story_id") var sid int fmt.Sscanf(storyID, "%d", &sid) // Get the current room to find old active story room, _ := lib.GetRoomByID(id) // Unreveal and clear votes on the old active story (if any) if room.ActiveStoryID != nil { lib.UnrevealStory(*room.ActiveStoryID) lib.ClearVotesForStory(*room.ActiveStoryID) } // Unreveal and clear votes on the new active story lib.UnrevealStory(sid) lib.ClearVotesForStory(sid) // Set the new active story lib.SetActiveStory(id, sid) broadcast(id, "refresh") http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleVote(w http.ResponseWriter, r *http.Request) { var id int fmt.Sscanf(r.PathValue("id"), "%d", &id) user := r.Context().Value(userKey).(*lib.User) value := r.FormValue("value") storyID := r.FormValue("story_id") var sid int fmt.Sscanf(storyID, "%d", &sid) lib.VoteOnStory(sid, user.ID, value) broadcast(id, "members") // Updates member panel with checkmarks if r.Header.Get("HX-Request") == "true" { room, _ := lib.GetRoomByID(id) stories, _ := lib.GetStoriesForRoom(id) var st lib.Story for _, s := range stories { if s.ID == sid { st = s break } } tmplData := struct { RoomID int Story lib.Story Scale string UserVote string }{ RoomID: id, Story: st, Scale: room.Scale, UserVote: value, } templates.ExecuteTemplate(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) { var id int fmt.Sscanf(r.PathValue("id"), "%d", &id) storyID := r.FormValue("story_id") var sid int fmt.Sscanf(storyID, "%d", &sid) lib.RevealVotes(sid) broadcast(id, "refresh") http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleUnrevealStory(w http.ResponseWriter, r *http.Request) { var id, sid int fmt.Sscanf(r.PathValue("id"), "%d", &id) fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) lib.UnrevealStory(sid) broadcast(id, "refresh") http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleEditStoryForm(w http.ResponseWriter, r *http.Request) { var id, sid int fmt.Sscanf(r.PathValue("id"), "%d", &id) fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) stories, _ := lib.GetStoriesForRoom(id) var story lib.Story for _, s := range stories { if s.ID == sid { story = s break } } tmplData := struct { RoomID int Story lib.Story }{ RoomID: id, Story: story, } if err := templates.ExecuteTemplate(w, "story_edit.html", tmplData); err != nil { log.Printf("template error: %v", err) } } func handleRenameStory(w http.ResponseWriter, r *http.Request) { var id, sid int fmt.Sscanf(r.PathValue("id"), "%d", &id) fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) title := r.FormValue("title") lib.RenameStory(sid, title) broadcast(id, "refresh") http.Redirect(w, r, fmt.Sprintf("/rooms/%d", id), http.StatusSeeOther) } func handleDeleteStory(w http.ResponseWriter, r *http.Request) { var id, sid int fmt.Sscanf(r.PathValue("id"), "%d", &id) fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) lib.DeleteStory(sid) broadcast(id, "refresh") // Return empty HTML so the story-card is removed from the DOM w.Header().Set("Content-Type", "text/html") w.Write([]byte("")) } func handleSSE(w http.ResponseWriter, r *http.Request) { var roomID int fmt.Sscanf(r.PathValue("room_id"), "%d", &roomID) 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 members to other clients so they see the new member go broadcast(roomID, "members") w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") flusher, ok := w.(http.Flusher) if !ok { return } notify := r.Context().Done() for { select { case <-notify: removeSSEClient(roomID, ch) // Broadcast members so others see member leave go broadcast(roomID, "members") return case msg := <-ch: fmt.Fprintf(w, "event: room-%d\ndata: %s\n\n", roomID, msg) flusher.Flush() } } }