diff --git a/VERSION.md b/VERSION.md index 6e8bf73..0ea3a94 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/api/auth.go b/api/auth.go index ba5506b..c6ad2a1 100644 --- a/api/auth.go +++ b/api/auth.go @@ -1,6 +1,7 @@ package api import ( + "log" "net/http" "time" @@ -26,12 +27,17 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { return } - sessionID, _ := lib.CreateSession(user.ID) + sessionID, err := lib.CreateSession(user.ID) + if err != nil { + http.Error(w, "failed to create session", http.StatusInternalServerError) + return + } http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: sessionID, Expires: time.Now().Add(24 * time.Hour), HttpOnly: true, + SameSite: http.SameSiteLaxMode, Path: "/", }) @@ -68,13 +74,16 @@ func handleRegister(w http.ResponseWriter, r *http.Request) { func handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_token") if err == nil { - lib.DeleteSession(cookie.Value) + if err := lib.DeleteSession(cookie.Value); err != nil { + log.Printf("delete session error: %v", err) + } } http.SetCookie(w, &http.Cookie{ - Name: "session_token", - Value: "", - Expires: time.Now().Add(-1 * time.Hour), - Path: "/", + Name: "session_token", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + SameSite: http.SameSiteLaxMode, + Path: "/", }) http.Redirect(w, r, "/login", http.StatusSeeOther) } diff --git a/api/dashboard.go b/api/dashboard.go index 92eca82..191fe72 100644 --- a/api/dashboard.go +++ b/api/dashboard.go @@ -23,7 +23,11 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } user := r.Context().Value(userKey).(*lib.User) - rooms, _ := lib.GetRoomsForUser(user.ID) + rooms, err := lib.GetRoomSummariesForUser(user.ID) + if err != nil { + http.Error(w, "failed to load rooms", http.StatusInternalServerError) + return + } data := struct { *lib.User Rooms []RoomView @@ -32,14 +36,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } 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), + MemberCount: room.MemberCount, }) } diff --git a/api/rooms.go b/api/rooms.go index d9984ff..59607f4 100644 --- a/api/rooms.go +++ b/api/rooms.go @@ -35,37 +35,64 @@ func handleJoinRoom(w http.ResponseWriter, r *http.Request) { http.Error(w, "room not found", http.StatusNotFound) return } - lib.AddUserToRoom(room.ID, user.ID) + if err := lib.AddUserToRoom(room.ID, user.ID); err != nil { + http.Error(w, "failed to join room", http.StatusInternalServerError) + return + } http.Redirect(w, r, fmt.Sprintf("/rooms/%d", room.ID), http.StatusSeeOther) } func handleRoom(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } 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) + if err := lib.AddUserToRoom(room.ID, user.ID); err != nil { + http.Error(w, "failed to join room", http.StatusInternalServerError) + return + } - renderTemplate(w, "room.html", buildRoomData(room, user)) + data, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + renderTemplate(w, "room.html", data) } func handlePartialStories(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) renderRoomStories(w, roomID, user) } func handlePartialMembers(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) renderRoomMembers(w, roomID, user) } func handlePartialVoteArea(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) room, err := lib.GetRoomByID(roomID) if err != nil { @@ -83,18 +110,27 @@ func handlePartialVoteArea(w http.ResponseWriter, r *http.Request) { return } + roomData, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } data := struct { RoomData RoomData Story lib.Story }{ - RoomData: buildRoomData(room, user), + RoomData: roomData, Story: *story, } renderTemplate(w, "vote-area", data) } func handleDeleteRoom(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) room, err := lib.GetRoomByID(roomID) diff --git a/api/sse.go b/api/sse.go index 4313388..26f8fef 100644 --- a/api/sse.go +++ b/api/sse.go @@ -9,17 +9,17 @@ import ( ) func handleSSE(w http.ResponseWriter, r *http.Request) { - roomID := getPathInt(r, "room_id") + roomID, err := getPathInt(r, "room_id") + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } 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") - w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -30,6 +30,11 @@ func handleSSE(w http.ResponseWriter, r *http.Request) { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } + + ch := make(chan string, 10) + addSSEClient(roomID, user.ID, ch) + broadcast(roomID, "members") + notify := r.Context().Done() heartbeat := time.NewTicker(25 * time.Second) defer heartbeat.Stop() diff --git a/api/stories.go b/api/stories.go index 610c7f2..d5471cb 100644 --- a/api/stories.go +++ b/api/stories.go @@ -9,13 +9,25 @@ import ( ) func handleNewStoryForm(w http.ResponseWriter, r *http.Request) { - roomID := getRoomID(r) - room, _ := lib.GetRoomByID(roomID) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + room, err := lib.GetRoomByID(roomID) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } renderTemplate(w, "story_form.html", room) } func handleAddStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) title := r.FormValue("title") if _, err := lib.CreateStory(id, title); err != nil { @@ -32,12 +44,22 @@ func handleAddStory(w http.ResponseWriter, r *http.Request) { } func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) + sid, err := getFormInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } - lib.SetActiveStory(id, sid) + if err := lib.SetActiveStory(id, sid); err != nil { + http.Error(w, "failed to set active story", http.StatusInternalServerError) + return + } broadcast(id, "stories") broadcast(id, "members") @@ -49,13 +71,26 @@ func handleSetActiveStory(w http.ResponseWriter, r *http.Request) { } func handleResetStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } - lib.UnrevealStory(sid) - lib.ClearVotesForStory(sid) + if err := lib.UnrevealStory(sid); err != nil { + http.Error(w, "failed to reset story", http.StatusInternalServerError) + return + } + if err := lib.ClearVotesForStory(sid); err != nil { + http.Error(w, "failed to clear votes", http.StatusInternalServerError) + return + } broadcast(id, "stories") broadcast(id, "members") @@ -67,13 +102,23 @@ func handleResetStory(w http.ResponseWriter, r *http.Request) { } func handleVote(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) value := r.FormValue("value") - var sid int - fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) + sid, err := getFormInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } - lib.VoteOnStory(sid, user.ID, value) + if err := lib.VoteOnStory(sid, user.ID, value); err != nil { + http.Error(w, "failed to vote", http.StatusInternalServerError) + return + } broadcast(id, "members") story, storyErr := lib.GetStoryByID(sid) if storyErr == nil && story.Voted { @@ -81,11 +126,15 @@ func handleVote(w http.ResponseWriter, r *http.Request) { } if isHTMX(r) { - room, _ := lib.GetRoomByID(id) if storyErr != nil { http.Error(w, "story not found", http.StatusNotFound) return } + room, err := lib.GetRoomByID(id) + if err != nil { + http.Error(w, "room not found", http.StatusNotFound) + return + } tmplData := struct { RoomID int Story lib.Story @@ -105,11 +154,21 @@ func handleVote(w http.ResponseWriter, r *http.Request) { } func handleReveal(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.FormValue("story_id"), "%d", &sid) - lib.RevealVotes(sid) + sid, err := getFormInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + if err := lib.RevealVotes(sid); err != nil { + http.Error(w, "failed to reveal votes", http.StatusInternalServerError) + return + } broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) @@ -119,11 +178,21 @@ func handleReveal(w http.ResponseWriter, r *http.Request) { } func handleUnrevealStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) - lib.UnrevealStory(sid) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + if err := lib.UnrevealStory(sid); err != nil { + http.Error(w, "failed to hide votes", http.StatusInternalServerError) + return + } broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) @@ -133,10 +202,21 @@ func handleUnrevealStory(w http.ResponseWriter, r *http.Request) { } 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) + roomID, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } + storyID, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + story, err := lib.GetStoryByID(storyID) + if err != nil { + http.Error(w, "story not found", http.StatusNotFound) + return + } data := struct { RoomID int Story lib.Story @@ -148,12 +228,22 @@ func handleEditStoryForm(w http.ResponseWriter, r *http.Request) { } func handleRenameStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } title := r.FormValue("title") - lib.RenameStory(sid, title) + if err := lib.RenameStory(sid, title); err != nil { + http.Error(w, "failed to rename story", http.StatusInternalServerError) + return + } broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) @@ -163,11 +253,21 @@ func handleRenameStory(w http.ResponseWriter, r *http.Request) { } func handleDeleteStory(w http.ResponseWriter, r *http.Request) { - id := getRoomID(r) + id, err := getRoomID(r) + if err != nil { + http.Error(w, "invalid room id", http.StatusBadRequest) + return + } user := r.Context().Value(userKey).(*lib.User) - var sid int - fmt.Sscanf(r.PathValue("story_id"), "%d", &sid) - lib.DeleteStory(sid) + sid, err := getPathInt(r, "story_id") + if err != nil { + http.Error(w, "invalid story id", http.StatusBadRequest) + return + } + if err := lib.DeleteStory(sid); err != nil { + http.Error(w, "failed to delete story", http.StatusInternalServerError) + return + } broadcast(id, "stories") if isHTMX(r) { renderRoomStories(w, id, user) diff --git a/api/templates.go b/api/templates.go index 0a3c69f..02c9967 100644 --- a/api/templates.go +++ b/api/templates.go @@ -72,7 +72,12 @@ func renderRoomStories(w http.ResponseWriter, roomID int, user *lib.User) { http.Error(w, "room not found", http.StatusNotFound) return } - renderTemplate(w, "stories-panel", buildRoomData(room, user)) + data, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + renderTemplate(w, "stories-panel", data) } func renderRoomMembers(w http.ResponseWriter, roomID int, user *lib.User) { @@ -81,7 +86,12 @@ func renderRoomMembers(w http.ResponseWriter, roomID int, user *lib.User) { http.Error(w, "room not found", http.StatusNotFound) return } - renderTemplate(w, "members-panel", buildRoomData(room, user)) + data, err := buildRoomData(room, user) + if err != nil { + http.Error(w, "failed to load room data", http.StatusInternalServerError) + return + } + renderTemplate(w, "members-panel", data) } func requireAuth(next http.HandlerFunc) http.HandlerFunc { diff --git a/api/types.go b/api/types.go index 7802726..bfb35a5 100644 --- a/api/types.go +++ b/api/types.go @@ -1,8 +1,9 @@ package api import ( - "fmt" + "errors" "net/http" + "strconv" "sync" "sprintpadawan/lib" @@ -35,7 +36,7 @@ type RoomData struct { var ( roomClients = make(map[int][]*sseClient) - clientsMu sync.Mutex + clientsMu sync.RWMutex ) func addSSEClient(roomID, userID int, ch chan string) { @@ -60,23 +61,48 @@ func removeSSEClient(roomID int, ch chan string) { } func broadcast(roomID int, event string) { - clientsMu.Lock() - defer clientsMu.Unlock() - alive := roomClients[roomID][:0] - for _, c := range roomClients[roomID] { + clientsMu.RLock() + snapshot := append([]*sseClient(nil), roomClients[roomID]...) + clientsMu.RUnlock() + + if len(snapshot) == 0 { + return + } + + deadSet := make(map[*sseClient]struct{}) + for _, c := range snapshot { select { case c.ch <- event: - alive = append(alive, c) default: - // drop dead client + deadSet[c] = struct{}{} } } + + if len(deadSet) == 0 { + return + } + + clientsMu.Lock() + defer clientsMu.Unlock() + + current := roomClients[roomID] + alive := current[:0] + for _, c := range current { + if _, dead := deadSet[c]; dead { + continue + } + alive = append(alive, c) + } + if len(alive) == 0 { + delete(roomClients, roomID) + return + } roomClients[roomID] = alive } func GetConnectedUserIDs(roomID int) []int { - clientsMu.Lock() - defer clientsMu.Unlock() + clientsMu.RLock() + defer clientsMu.RUnlock() seen := make(map[int]bool) var ids []int for _, c := range roomClients[roomID] { @@ -101,19 +127,45 @@ func scaleToOptions(scale string) []string { } } -func getRoomID(r *http.Request) int { +func getRoomID(r *http.Request) (int, error) { return getPathInt(r, "id") } -func getPathInt(r *http.Request, key string) int { - var id int - fmt.Sscanf(r.PathValue(key), "%d", &id) - return id +func getPathInt(r *http.Request, key string) (int, error) { + raw := r.PathValue(key) + if raw == "" { + return 0, errors.New("missing path parameter") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, errors.New("invalid path parameter") + } + return id, nil } -func buildRoomData(room *lib.Room, user *lib.User) RoomData { - members, _ := lib.GetRoomMembers(room.ID) - stories, _ := lib.GetStoriesForRoom(room.ID) +func getFormInt(r *http.Request, key string) (int, error) { + raw := r.FormValue(key) + if raw == "" { + return 0, errors.New("missing form value") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, errors.New("invalid form value") + } + return id, nil +} + +func buildRoomData(room *lib.Room, user *lib.User) (RoomData, error) { + members, err := lib.GetRoomMembers(room.ID) + if err != nil { + return RoomData{}, err + } + + stories, err := lib.GetStoriesForRoom(room.ID) + if err != nil { + return RoomData{}, err + } + connectedIDs := GetConnectedUserIDs(room.ID) connectedMap := make(map[int]bool) for _, cid := range connectedIDs { @@ -121,9 +173,30 @@ func buildRoomData(room *lib.Room, user *lib.User) RoomData { } connectedMap[user.ID] = true - var activeVotes []lib.Vote + storyIDs := make([]int, 0, len(stories)) + revealedStoryIDs := make([]int, 0, len(stories)) + for _, s := range stories { + storyIDs = append(storyIDs, s.ID) + if s.Voted { + revealedStoryIDs = append(revealedStoryIDs, s.ID) + } + } + + votesByStory, err := lib.GetVotesForStories(storyIDs) + if err != nil { + return RoomData{}, err + } + + storyVotes, err := lib.GetVoteViewsForStories(revealedStoryIDs) + if err != nil { + return RoomData{}, err + } + + activeVotesByUser := make(map[int]bool) if room.ActiveStoryID != nil { - activeVotes, _ = lib.GetVotesForStory(*room.ActiveStoryID) + for _, v := range votesByStory[*room.ActiveStoryID] { + activeVotesByUser[v.UserID] = true + } } var memberViews []MemberView @@ -131,13 +204,7 @@ func buildRoomData(room *lib.Room, user *lib.User) RoomData { if !connectedMap[m.ID] { continue } - hasVoted := false - for _, v := range activeVotes { - if v.UserID == m.ID { - hasVoted = true - break - } - } + hasVoted := activeVotesByUser[m.ID] memberViews = append(memberViews, MemberView{ Username: m.Username, HasVoted: hasVoted, @@ -146,18 +213,12 @@ func buildRoomData(room *lib.Room, user *lib.User) RoomData { } userVotes := make(map[int]string) - storyVotes := make(map[int][]lib.VoteView) for _, s := range stories { - votes, _ := lib.GetVotesForStory(s.ID) - for _, v := range votes { + for _, v := range votesByStory[s.ID] { if v.UserID == user.ID { userVotes[s.ID] = v.Value } } - if s.Voted { - vv, _ := lib.GetVotesWithUsernames(s.ID) - storyVotes[s.ID] = vv - } } return RoomData{ @@ -168,5 +229,5 @@ func buildRoomData(room *lib.Room, user *lib.User) RoomData { IsOwner: room.OwnerID == user.ID, UserVotes: userVotes, StoryVotes: storyVotes, - } + }, nil } diff --git a/lib/db.go b/lib/db.go index c1ac68c..ed26ffa 100644 --- a/lib/db.go +++ b/lib/db.go @@ -25,6 +25,10 @@ func InitDB() { log.Fatal(err) } + if _, err = DB.Exec("PRAGMA foreign_keys = ON;"); err != nil { + log.Fatal(err) + } + // make users table userTable := ` CREATE TABLE IF NOT EXISTS users ( @@ -43,7 +47,7 @@ func InitDB() { id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at DATETIME NOT NULL, - FOREIGN KEY(user_id) REFERENCES users(id) + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );` _, err = DB.Exec(sessionTable) if err != nil { @@ -60,7 +64,7 @@ func InitDB() { owner_id INTEGER NOT NULL, created_at DATETIME NOT NULL, active_story_id INTEGER, - FOREIGN KEY(owner_id) REFERENCES users(id) + FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE );` _, err = DB.Exec(roomTable) if err != nil { @@ -74,8 +78,8 @@ func InitDB() { user_id INTEGER NOT NULL, joined_at DATETIME NOT NULL, PRIMARY KEY (room_id, user_id), - FOREIGN KEY(room_id) REFERENCES rooms(id), - FOREIGN KEY(user_id) REFERENCES users(id) + FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );` _, err = DB.Exec(memberTable) if err != nil { @@ -90,7 +94,7 @@ func InitDB() { title TEXT NOT NULL, points INTEGER, voted INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(room_id) REFERENCES rooms(id) + FOREIGN KEY(room_id) REFERENCES rooms(id) ON DELETE CASCADE );` _, err = DB.Exec(storyTable) if err != nil { @@ -104,11 +108,27 @@ func InitDB() { user_id INTEGER NOT NULL, value TEXT NOT NULL, PRIMARY KEY (story_id, user_id), - FOREIGN KEY(story_id) REFERENCES stories(id), - FOREIGN KEY(user_id) REFERENCES users(id) + FOREIGN KEY(story_id) REFERENCES stories(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE );` _, err = DB.Exec(voteTable) if err != nil { log.Fatal(err) } + + indexes := []string{ + "CREATE INDEX IF NOT EXISTS idx_sessions_user_expires ON sessions(user_id, expires_at);", + "CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);", + "CREATE INDEX IF NOT EXISTS idx_rooms_owner ON rooms(owner_id);", + "CREATE INDEX IF NOT EXISTS idx_room_members_room ON room_members(room_id);", + "CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);", + "CREATE INDEX IF NOT EXISTS idx_stories_room ON stories(room_id);", + "CREATE INDEX IF NOT EXISTS idx_votes_story ON votes(story_id);", + "CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);", + } + for _, stmt := range indexes { + if _, err = DB.Exec(stmt); err != nil { + log.Fatal(err) + } + } } diff --git a/lib/room.go b/lib/room.go index f345b75..34e744a 100644 --- a/lib/room.go +++ b/lib/room.go @@ -1,6 +1,8 @@ package lib import ( + "fmt" + "strings" "time" "github.com/google/uuid" @@ -22,6 +24,11 @@ type RoomMember struct { JoinedAt time.Time } +type RoomSummary struct { + Room + MemberCount int +} + // generate unique room code func generateRoomCode() string { code := uuid.New().String()[:8] @@ -31,19 +38,42 @@ func generateRoomCode() string { // create room func CreateRoom(name string, ownerID int, scale string) (*Room, error) { code := generateRoomCode() - _, err := DB.Exec("INSERT INTO rooms (name, code, scale, owner_id, created_at, active_story_id) VALUES (?, ?, ?, ?, ?, NULL)", name, code, scale, ownerID, time.Now()) + + tx, err := DB.Begin() if err != nil { return nil, err } - room := &Room{Name: name, Code: code, Scale: scale, OwnerID: ownerID, ActiveStoryID: nil} - row := DB.QueryRow("SELECT id, name, code, scale, owner_id, created_at, active_story_id FROM rooms WHERE code = ?", code) - err = row.Scan(&room.ID, &room.Name, &room.Code, &room.Scale, &room.OwnerID, &room.CreatedAt, &room.ActiveStoryID) + defer tx.Rollback() + + createdAt := time.Now() + res, err := tx.Exec("INSERT INTO rooms (name, code, scale, owner_id, created_at, active_story_id) VALUES (?, ?, ?, ?, ?, NULL)", name, code, scale, ownerID, createdAt) if err != nil { return nil, err } - // add owner as member - DB.Exec("INSERT INTO room_members (room_id, user_id, joined_at) VALUES (?, ?, ?)", room.ID, ownerID, time.Now()) - return room, nil + + roomID64, err := res.LastInsertId() + if err != nil { + return nil, err + } + roomID := int(roomID64) + + if _, err := tx.Exec("INSERT INTO room_members (room_id, user_id, joined_at) VALUES (?, ?, ?)", roomID, ownerID, createdAt); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return &Room{ + ID: roomID, + Name: name, + Code: code, + Scale: scale, + OwnerID: ownerID, + CreatedAt: createdAt, + ActiveStoryID: nil, + }, nil } // get room by id @@ -70,6 +100,18 @@ func GetRoomByCode(code string) (*Room, error) { // get rooms for user func GetRoomsForUser(userID int) ([]Room, error) { + summaries, err := GetRoomSummariesForUser(userID) + if err != nil { + return nil, err + } + rooms := make([]Room, 0, len(summaries)) + for _, s := range summaries { + rooms = append(rooms, s.Room) + } + return rooms, nil +} + +func GetRoomSummariesForUser(userID int) ([]RoomSummary, error) { rows, err := DB.Query(` SELECT r.id, r.name, r.code, r.scale, r.owner_id, r.created_at, r.active_story_id, COUNT(rm.user_id) as member_count FROM rooms r @@ -81,14 +123,15 @@ func GetRoomsForUser(userID int) ([]Room, error) { return nil, err } defer rows.Close() - var rooms []Room + var rooms []RoomSummary for rows.Next() { - var r Room + var r RoomSummary var memberCount int err := rows.Scan(&r.ID, &r.Name, &r.Code, &r.Scale, &r.OwnerID, &r.CreatedAt, &r.ActiveStoryID, &memberCount) if err != nil { return nil, err } + r.MemberCount = memberCount rooms = append(rooms, r) } return rooms, nil @@ -135,3 +178,71 @@ func DeleteRoom(id int) error { _, err := DB.Exec("DELETE FROM rooms WHERE id = ?", id) return err } + +func GetVotesForStories(storyIDs []int) (map[int][]Vote, error) { + votesByStory := make(map[int][]Vote) + if len(storyIDs) == 0 { + return votesByStory, nil + } + + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(storyIDs)), ",") + args := make([]interface{}, 0, len(storyIDs)) + for _, id := range storyIDs { + args = append(args, id) + } + + query := fmt.Sprintf("SELECT story_id, user_id, value FROM votes WHERE story_id IN (%s)", placeholders) + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var v Vote + if err := rows.Scan(&v.StoryID, &v.UserID, &v.Value); err != nil { + return nil, err + } + votesByStory[v.StoryID] = append(votesByStory[v.StoryID], v) + } + + return votesByStory, nil +} + +func GetVoteViewsForStories(storyIDs []int) (map[int][]VoteView, error) { + voteViewsByStory := make(map[int][]VoteView) + if len(storyIDs) == 0 { + return voteViewsByStory, nil + } + + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(storyIDs)), ",") + args := make([]interface{}, 0, len(storyIDs)) + for _, id := range storyIDs { + args = append(args, id) + } + + query := fmt.Sprintf(` + SELECT v.story_id, u.username, v.value + FROM votes v + JOIN users u ON v.user_id = u.id + WHERE v.story_id IN (%s) + `, placeholders) + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var ( + storyID int + vv VoteView + ) + if err := rows.Scan(&storyID, &vv.Username, &vv.Value); err != nil { + return nil, err + } + voteViewsByStory[storyID] = append(voteViewsByStory[storyID], vv) + } + + return voteViewsByStory, nil +} diff --git a/lib/story.go b/lib/story.go index fa15d12..8e9afdf 100644 --- a/lib/story.go +++ b/lib/story.go @@ -2,7 +2,7 @@ package lib import ( "database/sql" - "fmt" + "strconv" ) type Story struct { @@ -22,14 +22,21 @@ type Vote struct { // create story func CreateStory(roomID int, title string) (*Story, error) { - _, err := DB.Exec("INSERT INTO stories (room_id, title, voted) VALUES (?, ?, 0)", roomID, title) + res, err := DB.Exec("INSERT INTO stories (room_id, title, voted) VALUES (?, ?, 0)", roomID, title) if err != nil { return nil, err } - s := &Story{RoomID: roomID, Title: title} - row := DB.QueryRow("SELECT id, room_id, title, points, voted FROM stories WHERE room_id = ? ORDER BY id DESC LIMIT 1", roomID) - err = row.Scan(&s.ID, &s.RoomID, &s.Title, &s.Points, &s.Voted) - return s, err + id64, err := res.LastInsertId() + if err != nil { + return nil, err + } + return &Story{ + ID: int(id64), + RoomID: roomID, + Title: title, + Points: nil, + Voted: false, + }, nil } // get stories for room @@ -138,11 +145,7 @@ func RenameStory(storyID int, title string) error { // delete story and its votes func DeleteStory(storyID int) error { - _, err := DB.Exec("DELETE FROM votes WHERE story_id = ?", storyID) - if err != nil { - return err - } - _, err = DB.Exec("DELETE FROM stories WHERE id = ?", storyID) + _, err := DB.Exec("DELETE FROM stories WHERE id = ?", storyID) return err } @@ -163,8 +166,7 @@ func tshirtToPoints(s string) float64 { case "?": return 0 default: - var n int - fmt.Sscanf(s, "%d", &n) + n, _ := strconv.Atoi(s) return float64(n) } } diff --git a/main.go b/main.go index fd89e48..18ee94c 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "io" "log" "net/http" + "path/filepath" + "regexp" "strings" "github.com/joho/godotenv" @@ -19,6 +21,8 @@ type gzipResponseWriter struct { http.ResponseWriter } +var fingerprintPattern = regexp.MustCompile(`[-.][a-f0-9]{8,}\.`) + func (w gzipResponseWriter) Write(b []byte) (int, error) { return w.Writer.Write(b) } @@ -43,7 +47,11 @@ func gzipMiddleware(next http.Handler) http.Handler { func cacheMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "public, max-age=31536000") + if fingerprintPattern.MatchString(filepath.Base(r.URL.Path)) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } next.ServeHTTP(w, r) }) }