package state import ( "encoding/json" "fmt" "log" "slices" "strings" "sync" "time" ) const ( defaultMaxActivityLogEntries = 400 defaultAdminLogBroadcastLimit = 200 defaultStaleRoomCleanupInterval = 5 * time.Minute defaultStaleRoomTTL = 30 * time.Minute ) type Manager struct { mu sync.RWMutex rooms map[string]*Room store *DiskStore maxActivityLogEntries int adminLogBroadcastLimit int staleRoomCleanupInterval time.Duration staleRoomTTL time.Duration } type ManagerOptions struct { MaxActivityLogEntries int AdminLogBroadcastLimit int StaleRoomCleanupInterval time.Duration StaleRoomTTL time.Duration } func normalizeManagerOptions(opts ManagerOptions) ManagerOptions { if opts.MaxActivityLogEntries <= 0 { opts.MaxActivityLogEntries = defaultMaxActivityLogEntries } if opts.AdminLogBroadcastLimit <= 0 { opts.AdminLogBroadcastLimit = defaultAdminLogBroadcastLimit } if opts.StaleRoomCleanupInterval <= 0 { opts.StaleRoomCleanupInterval = defaultStaleRoomCleanupInterval } if opts.StaleRoomTTL <= 0 { opts.StaleRoomTTL = defaultStaleRoomTTL } return opts } func NewManager(dataPath string, opts ManagerOptions) (*Manager, error) { store, err := NewDiskStore(dataPath) if err != nil { return nil, err } normalizedOpts := normalizeManagerOptions(opts) manager := &Manager{ rooms: make(map[string]*Room), store: store, maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries, adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit, staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval, staleRoomTTL: normalizedOpts.StaleRoomTTL, } if loadErr := manager.loadFromDisk(); loadErr != nil { return nil, loadErr } manager.startCleanupLoop() return manager, nil } func (m *Manager) startCleanupLoop() { ticker := time.NewTicker(m.staleRoomCleanupInterval) go func() { defer ticker.Stop() for range ticker.C { m.cleanupStaleRooms(nowUTC()) } }() } func (m *Manager) cleanupStaleRooms(now time.Time) { m.mu.RLock() rooms := make([]*Room, 0, len(m.rooms)) for _, room := range m.rooms { rooms = append(rooms, room) } m.mu.RUnlock() for _, room := range rooms { room.mu.Lock() roomID := room.ID hasConnected := hasConnectedParticipantsLocked(room) recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL hasSubscribers := len(room.subscribers) > 0 room.mu.Unlock() if hasConnected || recentlyActive || hasSubscribers { continue } m.mu.Lock() current, ok := m.rooms[roomID] if !ok || current != room { m.mu.Unlock() continue } current.mu.Lock() if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 { current.mu.Unlock() m.mu.Unlock() continue } delete(m.rooms, roomID) current.mu.Unlock() m.mu.Unlock() if err := m.store.Delete(roomID); err != nil { log.Printf("failed to delete stale room %s: %v", roomID, err) m.mu.Lock() if _, exists := m.rooms[roomID]; !exists { m.rooms[roomID] = room } m.mu.Unlock() } } } func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { roomName := normalizeName(input.RoomName, 80) creatorUsername := normalizeName(input.CreatorUsername, 32) if roomName == "" { roomName = "Scrum Poker Room" } if creatorUsername == "" { creatorUsername = "host" } maxPeople := input.MaxPeople if maxPeople < 2 { maxPeople = 2 } if maxPeople > 50 { maxPeople = 50 } revealMode := input.RevealMode if revealMode != RevealModeManual && revealMode != RevealModeAutoAll { revealMode = RevealModeManual } cards := make([]string, 0, len(input.Cards)) for _, rawCard := range input.Cards { card := normalizeCard(rawCard) if card == "" { continue } cards = append(cards, card) } if len(cards) == 0 { cards = []string{"0", "1", "2", "3", "5", "8", "13", "21", "?"} } roomID := newUUIDv4() adminToken := randomHex(24) creatorID := newUUIDv4() now := nowUTC() allowVoteChange := true if input.AllowVoteChange != nil { allowVoteChange = *input.AllowVoteChange } settings := RoomSettings{ RoomName: roomName, MaxPeople: maxPeople, Cards: cards, AllowSpectators: input.AllowSpectators, AnonymousVoting: input.AnonymousVoting, AutoReset: input.AutoReset, AllowVoteChange: allowVoteChange, RevealMode: revealMode, VotingTimeoutSec: max(0, input.VotingTimeoutSec), } password := strings.TrimSpace(input.Password) if password != "" { settings.PasswordSalt = randomHex(16) settings.PasswordHash = hashPassword(password, settings.PasswordSalt) } creator := &Participant{ ID: creatorID, SessionToken: randomHex(24), Username: creatorUsername, Role: RoleParticipant, IsAdmin: true, Connected: true, HasVoted: false, JoinedAt: now, UpdatedAt: now, } room := &Room{ ID: roomID, AdminToken: adminToken, CreatedAt: now, UpdatedAt: now, Settings: settings, Round: RoundState{ Revealed: false, }, Participants: map[string]*Participant{ creatorID: creator, }, ActivityLog: []ActivityLogEntry{}, subscribers: map[string]*subscriber{}, } m.appendActivityLogLocked(room, "%s created the room as admin.", creator.Username) m.mu.Lock() m.rooms[roomID] = room m.mu.Unlock() room.mu.Lock() if err := m.store.Save(room); err != nil { room.mu.Unlock() m.mu.Lock() delete(m.rooms, roomID) m.mu.Unlock() return CreateRoomResult{}, err } room.mu.Unlock() result := CreateRoomResult{ RoomID: roomID, CreatorParticipantID: creatorID, CreatorSessionToken: creator.SessionToken, AdminToken: adminToken, ParticipantLink: "/room/" + roomID, AdminLink: "/room/" + roomID + "?adminToken=" + adminToken, } return result, nil } func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, error) { room, err := m.getRoom(roomID) if err != nil { return JoinRoomResult{}, err } room.mu.Lock() defer room.mu.Unlock() username := normalizeName(input.Username, 32) if username == "" { username = "anonymous" } role := input.Role if role == "" { role = RoleParticipant } if role != RoleParticipant && role != RoleViewer { return JoinRoomResult{}, ErrInvalidRole } now := nowUTC() isAdminByToken := input.AdminToken != "" && input.AdminToken == room.AdminToken if room.Settings.PasswordHash != "" && input.ParticipantID == "" { if !passwordMatches(input.Password, room.Settings.PasswordSalt, room.Settings.PasswordHash) { return JoinRoomResult{}, ErrPasswordRequired } } if input.ParticipantID != "" { existing, ok := room.Participants[input.ParticipantID] if !ok { return JoinRoomResult{}, ErrParticipantNotFound } if !secureTokenMatches(existing.SessionToken, input.SessionToken) { return JoinRoomResult{}, ErrUnauthorized } wasConnected := existing.Connected existing.Username = username existing.Connected = true existing.UpdatedAt = now if isAdminByToken { existing.IsAdmin = true } if !wasConnected { m.appendActivityLogLocked(room, "%s joined as %s.", existing.Username, existing.Role) } room.UpdatedAt = now if err := m.store.Save(room); err != nil { return JoinRoomResult{}, err } go m.broadcastRoom(room.ID) return JoinRoomResult{ ParticipantID: existing.ID, SessionToken: existing.SessionToken, IsAdmin: existing.IsAdmin, Role: existing.Role, Username: existing.Username, }, nil } if role == RoleViewer && !room.Settings.AllowSpectators { return JoinRoomResult{}, ErrSpectatorsBlocked } if role == RoleParticipant { count := 0 for _, participant := range room.Participants { if participant.Role == RoleParticipant { count++ } } if count >= room.Settings.MaxPeople { return JoinRoomResult{}, ErrRoomFull } } participant := &Participant{ ID: newUUIDv4(), SessionToken: randomHex(24), Username: username, Role: role, IsAdmin: isAdminByToken, Connected: true, HasVoted: false, JoinedAt: now, UpdatedAt: now, } room.Participants[participant.ID] = participant m.appendActivityLogLocked(room, "%s joined as %s.", participant.Username, participant.Role) room.UpdatedAt = now if err := m.store.Save(room); err != nil { return JoinRoomResult{}, err } go m.broadcastRoom(room.ID) return JoinRoomResult{ ParticipantID: participant.ID, SessionToken: participant.SessionToken, IsAdmin: participant.IsAdmin, Role: participant.Role, Username: participant.Username, }, nil } func (m *Manager) LeaveRoom(roomID, participantID, sessionToken string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) if err != nil { return err } if !participant.Connected { return nil } m.disconnectParticipantLocked(room, participant) m.appendActivityLogLocked(room, "%s left the room.", participant.Username) if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) CastVote(roomID, participantID, sessionToken, card string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) if err != nil { return err } if participant.Role != RoleParticipant { return ErrUnauthorized } normalizedCard := normalizeCard(card) if normalizedCard == "" || !slices.Contains(room.Settings.Cards, normalizedCard) { return ErrInvalidCard } if participant.HasVoted { if participant.VoteValue == normalizedCard { return nil } if !room.Settings.AllowVoteChange { return ErrVoteChangeLocked } } previousVote := participant.VoteValue hadVoted := participant.HasVoted participant.HasVoted = true participant.VoteValue = normalizedCard participant.UpdatedAt = nowUTC() room.UpdatedAt = nowUTC() if hadVoted { m.appendActivityLogLocked(room, "%s changed vote from %s to %s.", participant.Username, previousVote, normalizedCard) } else { m.appendActivityLogLocked(room, "%s voted %s.", participant.Username, normalizedCard) } if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) { room.Round.Revealed = true m.appendActivityLogLocked(room, "Votes auto-revealed after all active participants voted.") } if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) RevealVotes(roomID, participantID, sessionToken string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) if err != nil { return err } if !participant.IsAdmin { return ErrUnauthorized } room.Round.Revealed = true room.UpdatedAt = nowUTC() m.appendActivityLogLocked(room, "%s revealed the votes.", participant.Username) if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) ResetVotes(roomID, participantID, sessionToken string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) if err != nil { return err } if !participant.IsAdmin { return ErrUnauthorized } m.resetVotesLocked(room) room.UpdatedAt = nowUTC() m.appendActivityLogLocked(room, "%s reset all votes.", participant.Username) if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) Subscribe(roomID, participantID, sessionToken string) (<-chan []byte, []byte, func(), error) { room, err := m.getRoom(roomID) if err != nil { return nil, nil, nil, err } room.mu.Lock() participant, authErr := m.authorizeParticipantLocked(room, participantID, sessionToken) if authErr != nil { room.mu.Unlock() return nil, nil, nil, authErr } participant.Connected = true participant.UpdatedAt = nowUTC() subscriberID := randomHex(12) ch := make(chan []byte, 16) room.subscribers[subscriberID] = &subscriber{participantID: participantID, ch: ch} room.UpdatedAt = nowUTC() if err := m.store.Save(room); err != nil { room.mu.Unlock() close(ch) delete(room.subscribers, subscriberID) return nil, nil, nil, err } room.mu.Unlock() initial, marshalErr := m.marshalRoomState(room, participantID) if marshalErr != nil { room.mu.Lock() delete(room.subscribers, subscriberID) close(ch) room.mu.Unlock() return nil, nil, nil, marshalErr } unsubscribe := func() { roomRef, getErr := m.getRoom(roomID) if getErr != nil { return } roomRef.mu.Lock() _, exists := roomRef.subscribers[subscriberID] if !exists { roomRef.mu.Unlock() return } delete(roomRef.subscribers, subscriberID) if p, participantOK := roomRef.Participants[participantID]; participantOK { if p.Connected { m.disconnectParticipantLocked(roomRef, p) m.appendActivityLogLocked(roomRef, "%s disconnected.", p.Username) _ = m.store.Save(roomRef) } } roomRef.mu.Unlock() go m.broadcastRoom(roomID) } go m.broadcastRoom(roomID) return ch, initial, unsubscribe, nil } func (m *Manager) getRoom(roomID string) (*Room, error) { m.mu.RLock() defer m.mu.RUnlock() room, ok := m.rooms[roomID] if !ok { return nil, ErrRoomNotFound } return room, nil } func (m *Manager) authorizeParticipantLocked(room *Room, participantID, sessionToken string) (*Participant, error) { participant, ok := room.Participants[participantID] if !ok { return nil, ErrParticipantNotFound } if !secureTokenMatches(participant.SessionToken, sessionToken) { return nil, ErrUnauthorized } return participant, nil } func (m *Manager) loadFromDisk() error { persistedRooms, err := m.store.LoadAll() if err != nil { return err } for _, persisted := range persistedRooms { allowVoteChange := true if persisted.Settings.AllowVoteChange != nil { allowVoteChange = *persisted.Settings.AllowVoteChange } settings := RoomSettings{ RoomName: persisted.Settings.RoomName, MaxPeople: persisted.Settings.MaxPeople, Cards: append([]string(nil), persisted.Settings.Cards...), AllowSpectators: persisted.Settings.AllowSpectators, AnonymousVoting: persisted.Settings.AnonymousVoting, AutoReset: persisted.Settings.AutoReset, AllowVoteChange: allowVoteChange, RevealMode: persisted.Settings.RevealMode, VotingTimeoutSec: persisted.Settings.VotingTimeoutSec, PasswordSalt: persisted.Settings.PasswordSalt, PasswordHash: persisted.Settings.PasswordHash, } room := &Room{ ID: persisted.ID, AdminToken: persisted.AdminToken, CreatedAt: persisted.CreatedAt, UpdatedAt: persisted.UpdatedAt, Settings: settings, Round: persisted.Round, Participants: make(map[string]*Participant, len(persisted.Participants)), ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...), subscribers: map[string]*subscriber{}, } for _, participant := range persisted.Participants { sessionToken := participant.SessionToken if sessionToken == "" { sessionToken = randomHex(24) } room.Participants[participant.ID] = &Participant{ ID: participant.ID, SessionToken: sessionToken, Username: participant.Username, Role: participant.Role, IsAdmin: participant.IsAdmin, Connected: false, HasVoted: participant.HasVoted, VoteValue: participant.VoteValue, JoinedAt: participant.JoinedAt, UpdatedAt: participant.UpdatedAt, } } m.rooms[room.ID] = room } return nil } func (room *Room) toPersisted() persistedRoom { allowVoteChange := room.Settings.AllowVoteChange participants := make([]*persistedParticipant, 0, len(room.Participants)) for _, participant := range sortParticipants(room.Participants) { participants = append(participants, &persistedParticipant{ ID: participant.ID, SessionToken: participant.SessionToken, Username: participant.Username, Role: participant.Role, IsAdmin: participant.IsAdmin, Connected: participant.Connected, HasVoted: participant.HasVoted, VoteValue: participant.VoteValue, JoinedAt: participant.JoinedAt, UpdatedAt: participant.UpdatedAt, }) } return persistedRoom{ ID: room.ID, AdminToken: room.AdminToken, CreatedAt: room.CreatedAt, UpdatedAt: room.UpdatedAt, Settings: persistedRoomSettings{ RoomName: room.Settings.RoomName, MaxPeople: room.Settings.MaxPeople, Cards: append([]string(nil), room.Settings.Cards...), AllowSpectators: room.Settings.AllowSpectators, AnonymousVoting: room.Settings.AnonymousVoting, AutoReset: room.Settings.AutoReset, AllowVoteChange: &allowVoteChange, RevealMode: room.Settings.RevealMode, VotingTimeoutSec: room.Settings.VotingTimeoutSec, PasswordSalt: room.Settings.PasswordSalt, PasswordHash: room.Settings.PasswordHash, }, Round: room.Round, Participants: participants, ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...), } } func allActiveParticipantsVoted(room *Room) bool { activeParticipants := 0 for _, participant := range room.Participants { if participant.Role != RoleParticipant || !participant.Connected { continue } activeParticipants++ if !participant.HasVoted { return false } } return activeParticipants > 0 } func (m *Manager) resetVotesLocked(room *Room) { room.Round.Revealed = false for _, participant := range room.Participants { if participant.Role != RoleParticipant { continue } participant.HasVoted = false participant.VoteValue = "" participant.UpdatedAt = nowUTC() } } func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]byte, error) { room.mu.RLock() defer room.mu.RUnlock() viewer, ok := room.Participants[viewerParticipantID] if !ok { return nil, ErrParticipantNotFound } participants := make([]PublicParticipant, 0, len(room.Participants)) for _, participant := range sortParticipants(room.Participants) { if !participant.Connected { continue } public := PublicParticipant{ ID: participant.ID, Username: participant.Username, Role: participant.Role, IsAdmin: participant.IsAdmin, Connected: participant.Connected, HasVoted: participant.HasVoted, } if room.Round.Revealed { public.VoteValue = participant.VoteValue } else if participant.ID == viewerParticipantID { public.VoteValue = participant.VoteValue } participants = append(participants, public) } state := PublicRoomState{ RoomID: room.ID, RoomName: room.Settings.RoomName, Cards: append([]string(nil), room.Settings.Cards...), Revealed: room.Round.Revealed, RevealMode: room.Settings.RevealMode, MaxPeople: room.Settings.MaxPeople, AllowSpectators: room.Settings.AllowSpectators, AnonymousVoting: room.Settings.AnonymousVoting, AutoReset: room.Settings.AutoReset, AllowVoteChange: room.Settings.AllowVoteChange, VotingTimeoutSec: room.Settings.VotingTimeoutSec, Participants: participants, SelfParticipantID: viewerParticipantID, ViewerIsAdmin: viewer.IsAdmin, Links: RoomLinks{ ParticipantLink: "/room/" + room.ID, }, } if viewer.IsAdmin { state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken start := 0 if len(room.ActivityLog) > m.adminLogBroadcastLimit { start = len(room.ActivityLog) - m.adminLogBroadcastLimit } state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start) for _, item := range room.ActivityLog[start:] { state.AdminLogs = append(state.AdminLogs, PublicActivityLogEntry{ At: item.At, Message: item.Message, }) } } return json.Marshal(state) } func (m *Manager) broadcastRoom(roomID string) { room, err := m.getRoom(roomID) if err != nil { return } type target struct { participantID string ch chan []byte } room.mu.RLock() targets := make([]target, 0, len(room.subscribers)) for _, subscriber := range room.subscribers { targets = append(targets, target{participantID: subscriber.participantID, ch: subscriber.ch}) } room.mu.RUnlock() for _, t := range targets { payload, marshalErr := m.marshalRoomState(room, t.participantID) if marshalErr != nil { continue } select { case t.ch <- payload: default: } } } func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any) { room.ActivityLog = append(room.ActivityLog, ActivityLogEntry{ At: nowUTC(), Message: fmt.Sprintf(format, args...), }) if len(room.ActivityLog) > m.maxActivityLogEntries { room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...) } } func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participant) { participant.Connected = false participant.UpdatedAt = nowUTC() room.UpdatedAt = nowUTC() } func hasConnectedParticipantsLocked(room *Room) bool { for _, participant := range room.Participants { if participant.Connected { return true } } return false }